diff --git a/.github/workflows/bun-profile-tests.yml b/.github/workflows/bun-profile-tests.yml deleted file mode 100644 index 1a292292510c..000000000000 --- a/.github/workflows/bun-profile-tests.yml +++ /dev/null @@ -1,171 +0,0 @@ -name: Bun profile persistence - -on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review] - paths: - - 'src/**' - - 'config/**' - - 'native/**' - - 'tests/**' - - 'resources/**' - - 'package.json' - - 'pnpm-lock.yaml' - - 'pnpm-workspace.yaml' - - 'tsconfig.json' - - '.npmrc' - - '.pnpmfile.cjs' - - '.github/actions/install-node-dependencies/**' - - '.github/workflows/bun-profile-tests.yml' - # The pull request qualifies one platform for an unflavoured change; this is where all six - # are re-qualified, so a platform break surfaces minutes after merge instead of next cron. - push: - branches: [main] - paths: - - 'src/**' - - 'config/**' - - 'native/**' - - 'tests/**' - - 'resources/**' - - 'package.json' - - 'pnpm-lock.yaml' - - 'pnpm-workspace.yaml' - - 'tsconfig.json' - - '.npmrc' - - '.pnpmfile.cjs' - - '.github/actions/install-node-dependencies/**' - - '.github/workflows/bun-profile-tests.yml' - workflow_dispatch: - schedule: - - cron: '30 11 * * *' - -permissions: - contents: read - -concurrency: - group: bun-profile-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - changes: - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - timeout-minutes: 5 - outputs: - should_run: ${{ steps.scope.outputs.should_run }} - qualification: ${{ steps.scope.outputs.qualification }} - runners: ${{ steps.scope.outputs.runners }} - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 2 - persist-credentials: false - - uses: ./.github/actions/install-node-dependencies - - name: Detect Bun build and test inputs - id: scope - shell: bash - run: | - # Compare the tested merge with its base, retaining both sides of renames. - if git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/bun-changes"; then - node config/scripts/bun-profile-change-scope.mjs "$RUNNER_TEMP/bun-changes" - else - echo 'should_run=true' >> "$GITHUB_OUTPUT" - fi - - persistence: - needs: changes - # Missing/failed detection runs the full matrix; manual runs remain unconditional. - # A draft carries no platform verdict; readiness re-triggers this workflow. Spelled against - # the event name so the push and schedule paths do not rest on a null property comparison. - if: >- - ${{ !cancelled() && needs.changes.outputs.should_run != 'false' && - (github.event_name != 'pull_request' || github.event.pull_request.draft != true) }} - strategy: - fail-fast: false - matrix: - os: ${{ fromJSON(needs.changes.outputs.runners || '["ubuntu-22.04","ubuntu-24.04-arm","macos-14","macos-15-intel","windows-2022","windows-11-arm"]') }} - runs-on: ${{ matrix.os }} - timeout-minutes: 20 - env: - ORCA_BACKGROUND_LAUNCH: '1' - steps: - - uses: actions/checkout@v6 - with: - persist-credentials: false - - uses: ./.github/actions/install-node-dependencies - with: - native-runtime: ${{ runner.os == 'Windows' && 'node' || 'none' }} - # build:orcad reuses the host addon validated by native-runtime preparation. - - run: pnpm build:orcad - - run: pnpm test:bun:profile --artifact - - uses: actions/setup-node@v6 - if: runner.arch == 'X64' - with: - node-version: '18' - - name: Verify Node 18 loads and hands off to bundled Bun - if: runner.arch == 'X64' - run: | - node out/orcad/orcad.js --orcad-smoke-load-check - node out/orcad/orcad.js --orcad-profile-state-preflight 00000000-0000-4000-8000-000000000018 - - linux_glibc_floor: - needs: [changes, persistence] - # A failed smoke already blocks qualification; missing scope still selects every platform. - if: >- - ${{ !cancelled() && needs.persistence.result == 'success' && - needs.changes.outputs.should_run != 'false' && - needs.changes.outputs.qualification != 'false' }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-22.04, ubuntu-24.04-arm] - runs-on: ${{ matrix.os }} - container: ubuntu:20.04 - timeout-minutes: 20 - env: - ORCA_BACKGROUND_LAUNCH: '1' - steps: - - name: Install Ubuntu 20.04 prerequisites - run: apt-get update && apt-get install -y build-essential ca-certificates git python3 unzip - - uses: actions/checkout@v6 - with: - persist-credentials: false - - name: Trust the checked-out workspace - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - uses: ./.github/actions/install-node-dependencies - - run: pnpm build:orcad - - run: pnpm test:bun:profile --artifact - - linux_musl: - needs: [changes, persistence] - # A failed smoke already blocks qualification; missing scope still selects every platform. - if: >- - ${{ !cancelled() && needs.persistence.result == 'success' && - needs.changes.outputs.should_run != 'false' && - needs.changes.outputs.qualification != 'false' }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-22.04, ubuntu-24.04-arm] - runs-on: ${{ matrix.os }} - timeout-minutes: 20 - env: - ORCA_BACKGROUND_LAUNCH: '1' - steps: - - uses: actions/checkout@v6 - with: - persist-credentials: false - - name: Verify native Alpine artifact and persistence - run: | - docker run --rm --init -i \ - -e ORCA_BACKGROUND_LAUNCH=1 \ - -v "$GITHUB_WORKSPACE:/work" -w /work \ - node:24-alpine3.23 sh -s <<'BUN_QUALIFICATION' - set -eu - apk add --no-cache bash git libstdc++ python3 make g++ - git config --global --add safe.directory /work - npm install -g "$(node -p "require('./package.json').packageManager.split('+')[0]")" - pnpm install --frozen-lockfile --ignore-scripts - pnpm build:orcad - pnpm test:bun:profile --artifact - BUN_QUALIFICATION diff --git a/.github/workflows/cloud-deploy-relay-asia-topology.yml b/.github/workflows/cloud-deploy-relay-asia-topology.yml index 5f594852eb5b..29c0e43f7376 100644 --- a/.github/workflows/cloud-deploy-relay-asia-topology.yml +++ b/.github/workflows/cloud-deploy-relay-asia-topology.yml @@ -76,6 +76,7 @@ jobs: staging:staging-gce-c4) ;; production:production-gce-c27,production-gce-c28,production-gce-c29) ;; production:production-gce-c30) ;; + production:production-gce-c31) ;; *) echo "cell-ids do not match the reviewed environment topology" >&2; exit 1 ;; esac [[ "${TARGET_IMAGE}" =~ ^us-central1-docker\.pkg\.dev/${GCP_PROJECT_ID}/orca-cloud/relay@sha256:[0-9a-f]{64}$ ]] diff --git a/.github/workflows/cloud-deploy-relay-production-capacity-job.yml b/.github/workflows/cloud-deploy-relay-production-capacity-job.yml index 4cf39571371b..792fd6918622 100644 --- a/.github/workflows/cloud-deploy-relay-production-capacity-job.yml +++ b/.github/workflows/cloud-deploy-relay-production-capacity-job.yml @@ -47,6 +47,9 @@ jobs: timeout-minutes: 75 environment: production env: + # These drains are unpaced, but restart-safe still needs a quiet runtime for the production + # pace window, the same one the same-cap drain uses. + RESTART_SAFE_PACE_WINDOW_MS: '300000' GCP_PROJECT_ID: onorca-cloud GCP_REGION: ${{ vars.PRODUCTION_GCP_REGION }} DIRECTOR_SERVICE_NAME: orca-cloud-relay @@ -565,6 +568,7 @@ jobs: --draining required \ --activity restart-safe \ --runtime required \ + --pace-window-ms "${RESTART_SAFE_PACE_WINDOW_MS}" \ --timeout-ms 450000 \ --expected-image-digests \ "${DESIRED_IMAGE_DIGEST},${PREDECESSOR_IMAGE_DIGEST}" \ @@ -615,6 +619,7 @@ jobs: --draining required \ --activity restart-safe \ --runtime required \ + --pace-window-ms "${RESTART_SAFE_PACE_WINDOW_MS}" \ --timeout-ms 450000 \ --expected-image-digests \ "${DESIRED_IMAGE_DIGEST},${PREDECESSOR_IMAGE_DIGEST}" @@ -676,6 +681,8 @@ jobs: --draining required \ --activity restart-safe \ --runtime required \ + --pace-window-ms "${RESTART_SAFE_PACE_WINDOW_MS}" \ + --timeout-ms 480000 \ --expected-image-digests \ "${DESIRED_IMAGE_DIGEST},${PREDECESSOR_IMAGE_DIGEST}" diff --git a/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml b/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml index 78b1099744e4..ffb35c9a7d15 100644 --- a/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml +++ b/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml @@ -278,7 +278,7 @@ jobs: EXPECTED_REGION=us-central1 EXPECTED_DATABASE_POOL_MAX= ;; - c27|c28|c29|c30) + c27|c28|c29|c30|c31) EXPECTED_HARD_CAP=3000 EXPECTED_REGION=asia-east2 EXPECTED_DATABASE_POOL_MAX=16 @@ -537,6 +537,17 @@ jobs: --draining "${PRECHECK_DRAINING}" --activity allowed \ --expected-image-digests "${PREDECESSOR_IMAGE_DIGEST}" + # Hosts drained with nowhere to go keep redialling and pin the cell empty (c28, 2026-10-01). + # Every run that drains (apply, rollback) checks first; verify reads the same answer; a resume drains nothing. + - name: Require free general-cell slots for the selected cell's hosts + if: ${{ env.ROLLBACK_RESUME != 'true' }} + env: + ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.deploy-auth.outputs.id_token }} + run: | + node dev/scripts/check-relay-same-cap-headroom.mjs \ + --director-origin "${DIRECTOR_ORIGIN}" --cell-origin "${CELL_ORIGIN}" \ + --cell-id "${TARGET_CELL_ID}" --general-cells "${EXPECTED_GENERAL_CELLS}" + - name: Finish read-only verification if: ${{ inputs.mode == 'verify' }} run: echo 'Exact same-cap rollback point verified.' @@ -572,14 +583,15 @@ jobs: --director-origin "${DIRECTOR_ORIGIN}" --cell-origin "${CELL_ORIGIN}" \ --cell-id "${TARGET_CELL_ID}" --approved-cells same-cap --mode drain \ --pace-window-ms "${DRAIN_PACE_WINDOW_MS}" - # The wait has to outlast the pacing window as well as the leases it waits on. + # Restart-safe means an empty runtime for a whole pace window after the drain; leases + # held by unplaceable hosts are reported, not waited on (c28, 2026-10-01). node dev/scripts/verify-relay-capacity-transition.mjs \ --director-origin "${DIRECTOR_ORIGIN}" --cell-origin "${CELL_ORIGIN}" \ --cell-id "${TARGET_CELL_ID}" --hard-cap "${EXPECTED_HARD_CAP}" \ --unobserved-bound "${EXPECTED_UNOBSERVED_BOUND}" \ --heartbeat either --admission migration-only --draining required \ --activity restart-safe --expected-image-digests "${PREDECESSOR_IMAGE_DIGEST}" \ - --timeout-ms 1200000 + --pace-window-ms "${DRAIN_PACE_WINDOW_MS}" --timeout-ms 1200000 - id: capacity-auth if: ${{ inputs.mode != 'verify' }} diff --git a/.github/workflows/cloud-operate-relay-asia-admission.yml b/.github/workflows/cloud-operate-relay-asia-admission.yml index 2c7c289da268..d8cea37a6d89 100644 --- a/.github/workflows/cloud-operate-relay-asia-admission.yml +++ b/.github/workflows/cloud-operate-relay-asia-admission.yml @@ -39,7 +39,7 @@ on: required: false type: string evidence-run-id: - description: Staging evidence run ID for C27, C27 canary run ID for C28/C29; C30 takes none and proves itself by its own canary + description: Staging evidence run ID for C27, C27 canary run ID for C28/C29; C30/C31 take none and each proves itself by its own canary required: false type: string evidence-run-attempt: @@ -155,9 +155,9 @@ jobs: evidence_kind=c27 artifact_name="relay-asia-c27-canary-${EVIDENCE_RUN_ID}-${EVIDENCE_RUN_ATTEMPT}" ;; - production-gce-c30) - # No earlier proof binds C30's generation; its own canary below rolls it back on failure. - canary_cell=production-gce-c30 + production-gce-c30|production-gce-c31) + # No earlier proof binds a later cell's generation; its own canary below rolls it back on failure. + canary_cell="${TARGET_CELL_IDS}" ;; *) echo "production promotion wave is not reviewed" >&2; exit 1 ;; esac @@ -317,15 +317,15 @@ jobs: shell: bash run: | set -euo pipefail - # C30's launch cells were checked general by the promotion; verify reads only C30's digest. + # A later cell's launch cells were checked general by the promotion; verify reads only its digest. case "${CANARY_CELL}" in production-gce-c27) verify_cells=production-gce-c27,production-gce-c28,production-gce-c29 expected_states='{"production-gce-c27":"general","production-gce-c28":"migration-only","production-gce-c29":"migration-only"}' ;; - production-gce-c30) - verify_cells=production-gce-c30 - expected_states='{"production-gce-c30":"general"}' + production-gce-c30|production-gce-c31) + verify_cells="${CANARY_CELL}" + expected_states="{\"${CANARY_CELL}\":\"general\"}" ;; *) exit 1 ;; esac diff --git a/.github/workflows/cloud-prove-relay-staging-capacity.yml b/.github/workflows/cloud-prove-relay-staging-capacity.yml index 52a7538d40bc..279007cbc146 100644 --- a/.github/workflows/cloud-prove-relay-staging-capacity.yml +++ b/.github/workflows/cloud-prove-relay-staging-capacity.yml @@ -69,6 +69,9 @@ jobs: DIRECTOR_SERVICE_NAME: orca-cloud-relay-staging DIRECTOR_ORIGIN: https://relay-staging.onorca.dev CELL_ORIGIN: https://c3.relay-staging.onorca.dev + # These drains are unpaced, but restart-safe still needs a quiet runtime for the production + # pace window, the same one the same-cap drain uses. + RESTART_SAFE_PACE_WINDOW_MS: '300000' TARGET_CELL_ID: staging-gce-c3 FALLBACK_CELL_ORIGIN: https://c2.relay-staging.onorca.dev FALLBACK_CELL_ID: staging-gce-c2 @@ -268,7 +271,8 @@ jobs: --heartbeat either \ --admission migration-only \ --draining required \ - --activity restart-safe + --activity restart-safe \ + --pace-window-ms "${RESTART_SAFE_PACE_WINDOW_MS}" --timeout-ms 480000 TRANSITION_PHASE=director-ready fi else @@ -377,7 +381,8 @@ jobs: --heartbeat either \ --admission migration-only \ --draining required \ - --activity restart-safe + --activity restart-safe \ + --pace-window-ms "${RESTART_SAFE_PACE_WINDOW_MS}" --timeout-ms 480000 - name: Verify current capacity if: ${{ inputs.mode == 'verify' }} @@ -451,7 +456,8 @@ jobs: --heartbeat "${HEARTBEAT_EXPECTATION}" \ --admission migration-only \ --draining required \ - --activity restart-safe + --activity restart-safe \ + --pace-window-ms "${RESTART_SAFE_PACE_WINDOW_MS}" --timeout-ms 480000 - name: Plan and apply only the exact empty cell if: ${{ inputs.mode == 'apply' }} diff --git a/.github/workflows/mobile-android-release.yml b/.github/workflows/mobile-android-release.yml index cdb5a59b1549..b9e6e1b1b021 100644 --- a/.github/workflows/mobile-android-release.yml +++ b/.github/workflows/mobile-android-release.yml @@ -85,11 +85,44 @@ jobs: echo "Mobile shell: $EXPO_PUBLIC_MOBILE_SHELL" cd android && ./gradlew assembleRelease + # Why: an install that fails with a missing certificate or a package-parse error is + # usually a download that died near the end, and the signature block sits in the last + # ~100 KB. Publishing the size and digest is what lets a reporter tell a truncated + # download apart from a bad build without anyone re-deriving them from the asset. + - name: Checksum the APK + id: apk + run: | + set -euo pipefail + shopt -s nullglob + apks=(android/app/build/outputs/apk/release/*.apk) + if [ "${#apks[@]}" -ne 1 ]; then + echo "Expected exactly one release APK, found ${#apks[@]}" >&2 + exit 1 + fi + + apk="${apks[0]}" + bytes="$(wc -c < "$apk" | tr -d ' ')" + sha256="$(sha256sum "$apk" | cut -d ' ' -f 1)" + # A sibling file in `sha256sum -c` format, so verifying a download is one command + # and no retyped digest. The asset globs below match *.apk and skip it. The ` *` + # marker is binary mode: Git Bash's sha256sum reads text mode as a licence to + # translate line endings while hashing, which would fail on a valid APK. + printf '%s *%s\n' "$sha256" "$(basename "$apk")" > "$apk.sha256" + + { + echo "checksum=$apk.sha256" + echo "bytes=$bytes" + echo "sha256=$sha256" + } >> "$GITHUB_OUTPUT" + echo "APK is $bytes bytes, sha256 $sha256" + - name: Upload APK artifact uses: actions/upload-artifact@v7 with: name: orca-mobile-apk - path: mobile/android/app/build/outputs/apk/release/*.apk + path: | + mobile/android/app/build/outputs/apk/release/*.apk + mobile/android/app/build/outputs/apk/release/*.apk.sha256 - name: Ensure GitHub release tag if: steps.release.outputs.publish_release == 'true' && !startsWith(github.ref, 'refs/tags/mobile-android-v') @@ -112,18 +145,62 @@ jobs: run: | set -euo pipefail tag="${{ steps.release.outputs.tag }}" + notes_file="$RUNNER_TEMP/android-release-notes.md" + + # printf rather than a heredoc: YAML block indentation would leak into the Markdown. + # Both platforms' commands are named because a reader on macOS has no sha256sum. + verification_section() { + printf '\n### Verify your download\n\n- Size: `%s` bytes\n- SHA-256: `%s`\n\nA "no certificate" or "problem parsing the package" install failure is usually a truncated download — check the size first, then `sha256sum -c app-release.apk.sha256` (`shasum -a 256 -c` on macOS).\n' \ + '${{ steps.apk.outputs.bytes }}' '${{ steps.apk.outputs.sha256 }}' + } + + # The section is always last, so dropping from its heading to EOF leaves the + # generated notes intact. \r* because a body round-tripped through the API has CRLFs. + drop_verification_section() { + sed '/^### Verify your download\r*$/,$d' + } + + # Why: reuse the desktop release path's character-safe truncation so a multi-byte + # character cannot be split at the cap. The section's own length is reserved out of + # that cap, because appending after truncating would push a near-limit body past + # GitHub's API limit and fail the call — on the upload path, after --clobber has + # already replaced the assets. `wc -c` counts bytes, so the section's multi-byte + # characters over-reserve, which errs toward a shorter body. + write_notes_with_verification() { + NOTES_FILE="$notes_file" \ + NOTES_RESERVE="$(verification_section | wc -c | tr -d ' ')" \ + NOTES_MODULE="$GITHUB_WORKSPACE/config/scripts/create-draft-release.mjs" \ + node --input-type=module -e ' + const { readFileSync, writeFileSync } = await import("node:fs") + const { pathToFileURL } = await import("node:url") + const { MAX_RELEASE_BODY_LENGTH, truncateReleaseBody } = await import( + pathToFileURL(process.env.NOTES_MODULE).href + ) + const file = process.env.NOTES_FILE + const max = MAX_RELEASE_BODY_LENGTH - Number(process.env.NOTES_RESERVE) + writeFileSync(file, truncateReleaseBody(readFileSync(file, "utf8"), max)) + ' + verification_section >> "$notes_file" + } if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then gh release upload "$tag" \ --repo "$GITHUB_REPOSITORY" \ --clobber \ - android/app/build/outputs/apk/release/*.apk + android/app/build/outputs/apk/release/*.apk \ + "${{ steps.apk.outputs.checksum }}" + # Why the body is rewritten too: --clobber replaced the APK, so a digest left + # over from the previous build now describes a file nobody can download, and a + # reader comparing against it would reject a good APK. + gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json body --jq .body \ + | drop_verification_section > "$notes_file" + write_notes_with_verification + gh release edit "$tag" --repo "$GITHUB_REPOSITORY" --notes-file "$notes_file" else # Why: release tags live on side branches, so GitHub's automatic # previous-tag detection reaches back several releases; that body # already exceeds the 125000-character API limit and grows each # release. Pin the comparison base and cap the size. - notes_file="$RUNNER_TEMP/android-release-notes.md" previous_tag="$( gh release list --repo "$GITHUB_REPOSITORY" --limit 200 --json tagName --jq '.[].tagName' \ | grep '^mobile-android-v' | grep -Fxv "$tag" | sort -V | tail -1 || true @@ -143,17 +220,7 @@ jobs: if [ ! -s "$notes_file" ]; then printf 'Orca Mobile Android %s\n' "$tag" > "$notes_file" fi - # Why: reuse the desktop release path's character-safe truncation so a - # multi-byte character cannot be split at the cap. - NOTES_FILE="$notes_file" \ - NOTES_MODULE="$GITHUB_WORKSPACE/config/scripts/create-draft-release.mjs" \ - node --input-type=module -e ' - const { readFileSync, writeFileSync } = await import("node:fs") - const { pathToFileURL } = await import("node:url") - const { truncateReleaseBody } = await import(pathToFileURL(process.env.NOTES_MODULE).href) - const file = process.env.NOTES_FILE - writeFileSync(file, truncateReleaseBody(readFileSync(file, "utf8"))) - ' + write_notes_with_verification gh release create "$tag" \ --repo "$GITHUB_REPOSITORY" \ @@ -161,5 +228,6 @@ jobs: --prerelease \ --latest=false \ --notes-file "$notes_file" \ - android/app/build/outputs/apk/release/*.apk + android/app/build/outputs/apk/release/*.apk \ + "${{ steps.apk.outputs.checksum }}" fi diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml new file mode 100644 index 000000000000..260a1250dda2 --- /dev/null +++ b/.github/workflows/node-server-tests.yml @@ -0,0 +1,263 @@ +name: Headless Node server + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'src/**' + - 'config/**' + - 'native/**' + - 'tests/**' + - 'resources/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'tsconfig.json' + - '.npmrc' + - '.pnpmfile.cjs' + - '.github/actions/install-node-dependencies/**' + - '.github/workflows/node-server-tests.yml' + # The pull request qualifies one platform for an unflavoured change; this is where all six + # are re-qualified, so a platform break surfaces minutes after merge instead of next cron. + push: + branches: [main] + paths: + - 'src/**' + - 'config/**' + - 'native/**' + - 'tests/**' + - 'resources/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'tsconfig.json' + - '.npmrc' + - '.pnpmfile.cjs' + - '.github/actions/install-node-dependencies/**' + - '.github/workflows/node-server-tests.yml' + workflow_dispatch: + schedule: + - cron: '30 11 * * *' + +permissions: + contents: read + +concurrency: + group: node-server-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + changes: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + should_run: ${{ steps.scope.outputs.should_run }} + qualification: ${{ steps.scope.outputs.qualification }} + runners: ${{ steps.scope.outputs.runners }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 2 + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + - name: Detect headless-server build and test inputs + id: scope + shell: bash + run: | + # Compare the tested merge with its base, retaining both sides of renames. + if git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/node-server-changes"; then + node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" + else + echo 'should_run=true' >> "$GITHUB_OUTPUT" + fi + + persistence: + needs: changes + # Missing/failed detection runs the full matrix; manual runs remain unconditional. + # A draft carries no platform verdict; readiness re-triggers this workflow. Spelled against + # the event name so the push and schedule paths do not rest on a null property comparison. + if: >- + ${{ !cancelled() && needs.changes.outputs.should_run != 'false' && + (github.event_name != 'pull_request' || github.event.pull_request.draft != true) }} + strategy: + fail-fast: false + matrix: + os: ${{ fromJSON(needs.changes.outputs.runners || '["ubuntu-22.04","ubuntu-24.04-arm","macos-14","macos-15-intel","windows-2022","windows-11-arm"]') }} + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: ${{ runner.os == 'Windows' && 'node' || 'none' }} + # Before setup-node 18: the scripts import the TypeScript runtime pin. + # Linux release slots come from the Ubuntu 20.04 and Alpine lanes below, where their libc + # floors live; this runner's slot only packages orcad for its own tests. + - name: Build and smoke this runner's node-pty prebuild slot under the pinned Node + shell: bash + run: | + pnpm build:orcad-prebuilds + pnpm build:orcad-prebuilds --require-slots "$(node config/scripts/build-orcad-prebuilds.mjs --print-slot)" + pnpm build:orcad-prebuilds --smoke + - run: pnpm build:orcad + # Design D7 upgrade and rollback: the last Bun orcad, built from a main commit that shipped + # it at this PROTOCOL_VERSION, beside this checkout's Node slot. Same lockfile, so its build + # reuses this checkout's node_modules. + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + if: runner.os == 'Linux' + with: + bun-version: 1.4.2 + - name: Build the last Bun orcad for the cross-runtime tests + if: runner.os == 'Linux' + shell: bash + env: + BUN_ORCAD_COMMIT: f4092c06d639ee13ad446261dcabc78b27a21fbc + run: | + git fetch --no-tags --depth=1 origin "$BUN_ORCAD_COMMIT" + git worktree add --detach "$RUNNER_TEMP/bun-orcad-source" "$BUN_ORCAD_COMMIT" + ln -s "$GITHUB_WORKSPACE/node_modules" "$RUNNER_TEMP/bun-orcad-source/node_modules" + node "$RUNNER_TEMP/bun-orcad-source/config/scripts/build-orcad-bun.mjs" --out-dir "$RUNNER_TEMP/bun-orcad" + echo "ORCA_BUN_ORCAD_SLOT=$RUNNER_TEMP/bun-orcad" >> "$GITHUB_ENV" + echo "BUN_EXECUTABLE=$(command -v bun)" >> "$GITHUB_ENV" + - run: pnpm test:node-server --artifact ${{ runner.os == 'Linux' && '--cross-runtime' || '' }} + - uses: actions/setup-node@v6 + if: runner.arch == 'X64' + with: + node-version: '18' + - name: Verify Node 18 loads and hands off to the pinned Node + if: runner.arch == 'X64' + shell: bash + run: | + node out/orcad/orcad.js --orcad-smoke-load-check + node out/orcad/orcad.js --orcad-profile-state-preflight 00000000-0000-4000-8000-000000000018 | tee "$RUNNER_TEMP/preflight.json" + node -e "const r=JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'));if(r.runtime!=='node'||!/^24\./.test(r.runtimeVersion))process.exit(1)" "$RUNNER_TEMP/preflight.json" + + linux_glibc_floor: + needs: [changes, persistence] + # A failed smoke already blocks qualification; missing scope still selects every platform. + if: >- + ${{ !cancelled() && needs.persistence.result == 'success' && + needs.changes.outputs.should_run != 'false' && + needs.changes.outputs.qualification != 'false' }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, ubuntu-24.04-arm] + # Design D6: server glibc slots are built and gated on glibc 2.28, where the pinned Node's + # own floor is; the desktop keeps its Ubuntu 20.04 gate. gcc-toolset-14 supplies C++20 and + # links newer libstdc++ symbols statically, so the slot needs only RHEL 8's GLIBCXX_3.4.25. + include: + - os: ubuntu-22.04 + image: quay.io/pypa/manylinux_2_28_x86_64@sha256:407f771c51a2c3e83ebe5a7970b4289ead3a6db21d9b9c089168775cad11d328 + - os: ubuntu-24.04-arm + image: quay.io/pypa/manylinux_2_28_aarch64@sha256:c22ffd129ac99a8a42d1f2c2f4e88a9089288dd9ee987a7092da1c7dc48f27a9 + runs-on: ${{ matrix.os }} + container: ${{ matrix.image }} + timeout-minutes: 25 + env: + ORCA_BACKGROUND_LAUNCH: '1' + # The image puts /opt/clang first on PATH; the slot is built with its gcc-toolset. + CC: gcc + CXX: g++ + PYTHON: /opt/python/cp312-cp312/bin/python3 + steps: + - name: Install glibc 2.28 prerequisites + run: dnf install -y git procps-ng unzip which xz + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Trust the checked-out workspace + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - uses: ./.github/actions/install-node-dependencies + - name: Build and smoke this runner's node-pty prebuild slot under the pinned Node + run: | + pnpm build:orcad-prebuilds + pnpm build:orcad-prebuilds --require-slots "$(node config/scripts/build-orcad-prebuilds.mjs --print-slot)" + pnpm build:orcad-prebuilds --smoke + - run: pnpm build:orcad + - run: pnpm test:node-server --artifact + + linux_glibc217_compat: + needs: [changes, persistence] + # A failed smoke already blocks qualification; missing scope still selects every platform. + if: >- + ${{ !cancelled() && needs.persistence.result == 'success' && + needs.changes.outputs.should_run != 'false' && + needs.changes.outputs.qualification != 'false' }} + runs-on: ubuntu-22.04 + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + # Design D6 rung B: the opt-in linux-x64-glibc217 slot beside the unofficial glibc-217 Node. + # Why docker run and not container: the runner's own Node for JS actions needs glibc 2.28, + # so the host installs and fetches the pinned Node, and the glibc 2.17 image only builds and + # smokes with that Node. devtoolset-10 supplies C++20; the slot links libstdc++ statically. + - name: Build and smoke the glibc 2.17 compat slot under the glibc-217 Node + run: | + compat_node="$(node config/scripts/build-orcad-prebuilds.mjs --slot=linux-x64-glibc217 --print-runtime)" + case "$compat_node" in + "$GITHUB_WORKSPACE"/*) ;; + *) echo "glibc-217 Node cached outside the workspace: $compat_node" >&2; exit 1 ;; + esac + docker run --rm --init -i \ + -e ORCA_BACKGROUND_LAUNCH=1 \ + -e COMPAT_NODE="/work/${compat_node#"$GITHUB_WORKSPACE"/}" \ + -e CC=gcc -e CXX=g++ \ + -e PYTHON=/opt/python/cp312-cp312/bin/python3 \ + -v "$GITHUB_WORKSPACE:/work" -w /work \ + quay.io/pypa/manylinux2014_x86_64@sha256:6f74cabeac2432570aa4bfdb29f7c1f30313d4d6654d764c44e574b6ffdd4ed5 bash -s <<'GLIBC217_COMPAT_SLOT' + set -eu + export PATH="$(dirname "$COMPAT_NODE"):$PATH" + node config/scripts/build-orcad-prebuilds.mjs --slot=linux-x64-glibc217 + node config/scripts/build-orcad-prebuilds.mjs --require-slots linux-x64-glibc217 + # The image's devtoolset LD_LIBRARY_PATH must not stand in for a host C++ runtime. + env -u LD_LIBRARY_PATH node config/scripts/build-orcad-prebuilds.mjs --slot=linux-x64-glibc217 --smoke + GLIBC217_COMPAT_SLOT + + linux_musl: + needs: [changes, persistence] + # A failed smoke already blocks qualification; missing scope still selects every platform. + if: >- + ${{ !cancelled() && needs.persistence.result == 'success' && + needs.changes.outputs.should_run != 'false' && + needs.changes.outputs.qualification != 'false' }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, ubuntu-24.04-arm] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Verify native Alpine artifact and persistence + run: | + # Multi-arch index digest of the tag; re-resolve it whenever NODE_RUNTIME_PIN moves. + docker run --rm --init -i \ + -e ORCA_BACKGROUND_LAUNCH=1 \ + -v "$GITHUB_WORKSPACE:/work" -w /work \ + node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 sh -s <<'NODE_SERVER_QUALIFICATION' + set -eu + apk add --no-cache bash git libstdc++ python3 make g++ + git config --global --add safe.directory /work + npm install -g "$(node -p "require('./package.json').packageManager.split('+')[0]")" + pnpm install --frozen-lockfile --ignore-scripts + pnpm build:orcad-prebuilds + pnpm build:orcad-prebuilds --require-slots "$(node config/scripts/build-orcad-prebuilds.mjs --print-slot)" + pnpm build:orcad-prebuilds --smoke + pnpm build:orcad + pnpm test:node-server --artifact + NODE_SERVER_QUALIFICATION diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 96079a165d45..e54431318ab6 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -35,7 +35,6 @@ jobs: should_run: ${{ steps.filter.outputs.should_run }} reused_run_id: ${{ steps.readiness.outputs.run_id }} # A proven success masks required work only; advisory routing still uses the full diff. - native_cache_changed: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.native_cache_changed }} mobile_dependencies: ${{ steps.filter.outputs.mobile_dependencies }} mobile_web_app: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.mobile_web_app }} static_analysis: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.static_analysis }} @@ -181,11 +180,13 @@ jobs: if: needs.code_paths.outputs.mobile_dependencies != 'true' with: native-runtime: node + node-version: '24' - uses: ./.github/actions/install-node-dependencies if: needs.code_paths.outputs.mobile_dependencies == 'true' with: native-runtime: node + node-version: '24' cache-dependency-path: | pnpm-lock.yaml mobile/pnpm-lock.yaml @@ -267,6 +268,9 @@ jobs: - name: Enforce runtime Electron-import ratchet run: pnpm run check:runtime-electron-ratchet + - name: Check Node runtime pin + run: pnpm run check:node-runtime-pin + # Why: extraction writes sorted evidence to an isolated temporary path, # so feature PRs need one normalized AST pass rather than a three-OS matrix. - name: Verify localization extraction @@ -348,11 +352,14 @@ jobs: if: needs.code_paths.outputs.typecheck == 'true' # Typechecking uses no native runtime, so it can use the free public ARM runner. runs-on: ubuntu-24.04-arm + outputs: + shards: ${{ steps.unit-plan.outputs.shards }} steps: - name: Checkout uses: actions/checkout@v6 with: + fetch-depth: 2 persist-credentials: false - uses: ./.github/actions/install-node-dependencies @@ -369,8 +376,24 @@ jobs: restore-keys: | tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}- + # Planning shares setup and stays off the compiler's critical path. + - name: Plan unit selection + id: unit-plan + background: true + env: + ORCA_UNIT_SELECTION_MODE: ${{ vars.ORCA_UNIT_SELECTION_MODE || 'shadow' }} + run: node config/scripts/ci-unit-plan.mjs + - run: pnpm run typecheck + - wait: unit-plan + + - uses: actions/upload-artifact@v7 + with: + name: unit-selection-attempt-${{ github.run_attempt }} + path: ci-shards/unit-selection.json + retention-days: 14 + git_compatibility: name: Git compatibility needs: [code_paths] @@ -654,51 +677,20 @@ jobs: src/shared/startup-shell-portability.live-shell.test.ts \ src/shared/posix-command-path-lookup.test.ts - # Cache-key input changes would otherwise make every shard compile the same - # native addon concurrently. Prime the supported Node ABI before the matrix fans out. - test_native_cache: - name: prepare test native cache node 24 - needs: [code_paths] - if: needs.code_paths.outputs.native_cache_changed == 'true' - runs-on: ubuntu-24.04-arm - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - persist-credentials: false - - - uses: ./.github/actions/install-node-dependencies - with: - native-runtime: node - node-version: '24' - - # Why hoisted out of unit-tests.yml: a caller's `needs` gate the whole called workflow, so - # while planning lived in there it queued behind static analysis and typecheck and the shards - # then queued behind it. Planning needs none of their output, so running it against - # code_paths alone overlaps it with the gate. Measured a median 93s off the matrix's start. - unit_plan: - needs: [code_paths] - if: needs.code_paths.outputs.test == 'true' - uses: ./.github/workflows/unit-plan.yml - with: - selection_mode: ${{ vars.ORCA_UNIT_SELECTION_MODE || 'shadow' }} - + # Static analysis saves the Node cache; typecheck publishes the plan before tests fan out. test: - needs: [code_paths, unit_plan, test_native_cache, static_analysis, typecheck] - # Honor cancellation while allowing the optional native-cache primer to skip. + needs: [code_paths, static_analysis, typecheck] + # Cancellation and failed prerequisites stop the expensive matrix. if: >- !cancelled() && needs.code_paths.outputs.test == 'true' && - needs.unit_plan.result == 'success' && needs.static_analysis.result == 'success' && - needs.typecheck.result == 'success' && - (needs.test_native_cache.result == 'success' || needs.test_native_cache.result == 'skipped') + needs.typecheck.result == 'success' uses: ./.github/workflows/unit-tests.yml with: node_versions: '["24"]' runner: ubuntu-24.04-arm - shards: ${{ needs.unit_plan.outputs.shards }} + shards: ${{ needs.typecheck.outputs.shards }} # Why a sibling and not part of the test workflow: it is advisory, so it must not delay the # gate. Inside unit-tests.yml a caller's `needs: test` waited for it, holding verify ~36s @@ -849,6 +841,20 @@ jobs: with: native-runtime: node + # R1: an upgrade from the newest release must adopt its live terminal daemon. + - name: Daemon protocol crossing against newest release + run: pnpm run check:daemon-protocol-crossing + + # R3: a runtime launcher swap must not also bump the daemon protocol (D7.1). + # The label is read from the event payload, so push again after applying it. + - name: Runtime launcher change does not bump daemon protocol + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + ORCA_ALLOW_RUNTIME_LAUNCHER_PROTOCOL_BUMP: ${{ contains(github.event.pull_request.labels.*.name, 'allow-runtime-launcher-protocol-bump') }} + run: | + DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")" + pnpm run check:runtime-launcher-protocol-ratchet -- --base "$DIFF_BASE" + # A path filter that matches nothing exits 1 ("No test files found"), so this # lane cannot report success while running zero tests. - name: Old/new client and server compatibility journeys @@ -862,7 +868,6 @@ jobs: tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts - tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts tests/e2e/cross-version-wire/cross-version-worktree-ps-verdict.unit.test.ts managed_hook_node18: @@ -1125,6 +1130,7 @@ jobs: src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts src/main/codex/windows-hook-command.test.ts src/main/codex/windows-hook-upgrade.test.ts + src/main/codex/hook-service-managed-install.test.ts src/main/windows/windows-pty-job.win32.test.ts src/main/windows/windows-msys-job.win32.test.ts src/main/providers/agent-foreground-process-git-bash.win32.test.ts diff --git a/.gitignore b/.gitignore index 65bb5b211b29..dbb74b9c9504 100644 --- a/.gitignore +++ b/.gitignore @@ -124,6 +124,7 @@ docs/** !docs/reference/agent-status-store.md !docs/reference/antigravity-readiness-evidence.md !docs/reference/antivirus-prerelease-clearance.md +!docs/reference/cline-and-prime-agent-readiness-evidence.md !docs/reference/git-compatibility.md !docs/reference/headless-linux-server.md !docs/reference/ime-regression-checklist.md diff --git a/cloud/apps/relay/src/app.ts b/cloud/apps/relay/src/app.ts index cb3a47f7f010..1671227e7ab1 100644 --- a/cloud/apps/relay/src/app.ts +++ b/cloud/apps/relay/src/app.ts @@ -28,6 +28,7 @@ import { createRuntimeTokenVerifier } from './admin-token-verifier.js' import { + RelayAssignmentRowBusyError, RelayHomeCellUnavailableError, type CellFenceAttemptEvidence, type RelayAssignment, @@ -58,6 +59,8 @@ const RelayCellConnectionHardCapSchema = z.custom( ) const ASSIGNMENT_REJECTION_LOG_WINDOW_MS = 10_000 +// A release holds the row for about one lock timeout, so one second is enough. +const ASSIGNMENT_ROW_BUSY_RETRY_AFTER_SECONDS = 1 const REGION_CATALOG_CACHE_MS = 30_000 // A drain that outlives the roll step it belongs to is an outage, not a pacing win. const DRAIN_PACE_WINDOW_MAX_MS = 5 * 60 * 1_000 @@ -362,6 +365,18 @@ export function createRelayApp( } } } catch (error) { + if (error instanceof RelayAssignmentRowBusyError) { + logAssignmentRejection({ + route: 'assign', + lane, + hinted: Boolean(body.data.reconnect), + relayHostId: claims.relayHostId, + reason: operationError(error) + }) + // The host's own release is settling; its next dial finds the row free. + context.header('Retry-After', String(ASSIGNMENT_ROW_BUSY_RETRY_AFTER_SECONDS)) + return context.json({ error: 'assignment_row_busy' }, 503) + } if (isRelayAssignmentUnavailableError(error) || isRelayDatabaseTransientError(error)) { logAssignmentRejection({ route: 'assign', @@ -481,6 +496,9 @@ export function createRelayApp( }) } if (isRelayAssignmentUnavailableError(error)) { + if (error instanceof RelayAssignmentRowBusyError) { + context.header('Retry-After', String(ASSIGNMENT_ROW_BUSY_RETRY_AFTER_SECONDS)) + } return context.json({ error: operationError(error) }, 503) } if (isRelayDatabaseTransientError(error)) return rejectPublicAssignment(context) @@ -1983,7 +2001,8 @@ function isRelayAssignmentUnavailableError(error: unknown): boolean { [ 'relay_capacity_exhausted', 'relay_connection_headroom_exhausted', - 'relay_home_cell_unavailable' + 'relay_home_cell_unavailable', + 'relay_assignment_row_busy' ].includes(error.message) ) } diff --git a/cloud/apps/relay/src/assignment-isolated-cell-replacement-postgres.test.ts b/cloud/apps/relay/src/assignment-isolated-cell-replacement-postgres.test.ts index 678c6350303a..994fd8aa0ab1 100644 --- a/cloud/apps/relay/src/assignment-isolated-cell-replacement-postgres.test.ts +++ b/cloud/apps/relay/src/assignment-isolated-cell-replacement-postgres.test.ts @@ -1,6 +1,7 @@ +import { ASSIGNMENT_LIMITS } from '@orca-cloud/relay-contract' import { createHash } from 'node:crypto' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { RelayAssignmentStore } from './assignment-store.js' +import { RelayAssignmentRowBusyError, RelayAssignmentStore } from './assignment-store.js' import { encodeMembership, type CellAdmissionMembership, @@ -39,7 +40,14 @@ const TARGETS: RelayCellConfig[] = [ ...CAPPED } ] -const CELLS = [ISOLATED, ...TARGETS] +// The next tier: taken only when the source's region has no general room. +const OTHER_REGION: RelayCellConfig = { + id: 'isolated-replacement-other-region', + url: 'https://isolated-replacement-other-region.example.com', + region: 'asia-east2', + ...CAPPED +} +const CELLS = [ISOLATED, ...TARGETS, OTHER_REGION] const HOST_COUNT = 50 function hostIdentity(index: number): { userId: string; relayHostId: string } { @@ -62,7 +70,102 @@ describePostgres('PostgreSQL re-placement off a cell isolated for a roll', () => return Number(rows[0]!['reserved_requests']) } - async function heartbeatAll(): Promise { + // The accounting invariant the migration paths assert: a cell's counter is + // exactly the units of the leases held on it. + async function expectReservationAccounting(): Promise { + for (const cell of CELLS) { + const leased = await databases[0]!.query( + `SELECT COALESCE(SUM(request_units), 0) AS units + FROM relay_assignment_activity_leases WHERE cell_id = ?`, + [cell.id] + ) + expect({ cell: cell.id, reserved: await reservedRequests(cell.id) }).toEqual({ + cell: cell.id, + reserved: Number(leased[0]!['units']) + }) + } + } + + async function expectReservationAccountingExcept(cellId: string): Promise { + for (const cell of CELLS.filter(({ id }) => id !== cellId)) { + const leased = await databases[0]!.query( + `SELECT COALESCE(SUM(request_units), 0) AS units + FROM relay_assignment_activity_leases WHERE cell_id = ?`, + [cell.id] + ) + expect(await reservedRequests(cell.id)).toBe(Number(leased[0]!['units'])) + } + } + + async function counters(identity: { + userId: string + relayHostId: string + }): Promise<{ controls: number; splices: number }> { + const rows = await databases[0]!.query( + `SELECT reserved_controls, reserved_splices FROM relay_assignments + WHERE user_id = ? AND relay_host_id = ?`, + [identity.userId, identity.relayHostId] + ) + return { + controls: Number(rows[0]!['reserved_controls']), + splices: Number(rows[0]!['reserved_splices']) + } + } + + // Holds the named relay_cells rows in another session until released, the + // way a release from a far cell holds its row across a round trip. + async function holdCellRows(cellIds: string[]): Promise<() => Promise> { + return await holdRows( + `SELECT cell_id FROM relay_cells WHERE cell_id IN (${cellIds.map(() => '?').join(', ')}) + ORDER BY cell_id ASC`, + cellIds + ) + } + + async function holdRows(sql: string, params: unknown[]): Promise<() => Promise> { + let release!: () => void + const released = new Promise((resolve) => (release = resolve)) + let held!: () => void + const acquired = new Promise((resolve) => (held = resolve)) + const holder = databases[3]!.transaction(async (transaction) => { + await transaction.queryLocked(sql, params) + held() + await released + }) + await acquired + return async () => { + release() + await holder + } + } + + // Puts a host on the source with a live control and a splice, then isolates + // the source for a roll with both targets open. + async function seedActiveHostOnIsolatedSource(index: number): Promise<{ + identity: { userId: string; relayHostId: string } + first: { cellId: string; assignmentEpoch: number } + }> { + const identity = hostIdentity(index) + await applySelector({ [TARGETS[0]!.id]: 'migration-only', [TARGETS[1]!.id]: 'migration-only' }) + const first = await stores[0]!.assign(identity, 'us-central1') + expect(first.cellId).toBe(ISOLATED.id) + await stores[0]!.activateControl(identity, { + cellId: ISOLATED.id, + assignmentEpoch: first.assignmentEpoch, + generation: 1 + }) + await stores[0]!.acquireActivity(identity, { + activityId: 'splice:keep-1', + kind: 'splice', + cellId: ISOLATED.id + }) + await applySelector({ [TARGETS[0]!.id]: 'general', [TARGETS[1]!.id]: 'general' }) + await applySelector({ [ISOLATED.id]: 'migration-only' }, [ISOLATED.id]) + return { identity, first } + } + + // `enforced` puts a cell at its connection cap, which leaves it no headroom. + async function heartbeatAll(enforced: Record = {}): Promise { for (const [index, cell] of CELLS.entries()) { await stores[0]!.recordCellHeartbeat({ cellId: cell.id, @@ -72,10 +175,11 @@ describePostgres('PostgreSQL re-placement off a cell isolated for a roll', () => ready: true, observedRequests: 0, region: cell.region, - totalConnections: 0, + // The cell checks enforced = total + in-flight + reserved. + totalConnections: enforced[cell.id] ?? 0, inFlightConnections: 0, reservedConnectionUnits: 0, - enforcedConnectionUnits: 0, + enforcedConnectionUnits: enforced[cell.id] ?? 0, connectionHardCap: 600, connectionUnobservedBound: 50 }) @@ -113,7 +217,11 @@ describePostgres('PostgreSQL re-placement off a cell isolated for a roll', () => else membership.general.push(cellId) } await stores[0]!.applyCellAdmissionSelector({ - attemptId: `isolated-${current.selector.generation}-${Object.keys(states).join('-')}`, + // The id is capped at 128 characters; the generation already makes it unique. + attemptId: `isolated-${current.selector.generation}-${createHash('sha256') + .update(Object.keys(states).join('-')) + .digest('hex') + .slice(0, 16)}`, expectedGeneration: current.selector.generation, ...(current.selector.generation === 0 ? { @@ -140,6 +248,12 @@ describePostgres('PostgreSQL re-placement off a cell isolated for a roll', () => // isolated would otherwise starve the next one of placement candidates. async function resetFleet(): Promise { await deleteHostRows() + // Only this file's hosts hold leases on these cells, so zero is exact. + await databases[0]!.query( + `UPDATE relay_cells SET reserved_requests = 0 + WHERE cell_id IN (${CELLS.map(() => '?').join(', ')})`, + CELLS.map((cell) => cell.id) + ) await applySelector(Object.fromEntries(CELLS.map((cell) => [cell.id, 'general']))) } @@ -293,10 +407,23 @@ describePostgres('PostgreSQL re-placement off a cell isolated for a roll', () => expect(grant.assignmentEpoch).toBe(first.get(identity.relayHostId)!.assignmentEpoch + 1) } - expect(await reservedRequests(ISOLATED.id)).toBe(sourceBaseline) + // The placement leaves the source row alone: each host's pending control + // there still counts until the source releases it. + expect(await reservedRequests(ISOLATED.id)).toBe(sourceBaseline + HOST_COUNT) expect( (await reservedRequests(TARGETS[0]!.id)) + (await reservedRequests(TARGETS[1]!.id)) ).toBe(targetBaseline + HOST_COUNT) + await expectReservationAccounting() + for (const identity of identities) { + expect( + await stores[0]!.releaseActivity( + identity, + `control-pending:${first.get(identity.relayHostId)!.assignmentEpoch}` + ) + ).toBe(true) + } + expect(await reservedRequests(ISOLATED.id)).toBe(sourceBaseline) + await expectReservationAccounting() const rows = await databases[0]!.query( `SELECT COUNT(*) AS count FROM relay_assignments @@ -331,13 +458,13 @@ describePostgres('PostgreSQL re-placement off a cell isolated for a roll', () => expect(granted.length).toBeGreaterThan(0) // However many dials were granted, only one re-placement may have - // committed: the epoch advances by exactly one and the reservation moves - // exactly one unit. + // committed: the epoch advances by exactly one and the target gains exactly + // one unit. The source keeps its lease until the source releases it. const settled = await stores[0]!.resolve(identity) expect(settled?.assignmentEpoch).toBe(first.assignmentEpoch + 1) expect(settled?.cellId).not.toBe(ISOLATED.id) for (const grant of granted) expect(grant.cellId).toBe(settled?.cellId) - expect(await reservedRequests(ISOLATED.id)).toBe(sourceBefore - 1) + expect(await reservedRequests(ISOLATED.id)).toBe(sourceBefore) expect( (await reservedRequests(TARGETS[0]!.id)) + (await reservedRequests(TARGETS[1]!.id)) ).toBe(targetBaseline + 1) @@ -361,4 +488,405 @@ describePostgres('PostgreSQL re-placement off a cell isolated for a roll', () => first.assignmentEpoch + 1 ) }, 30_000) + it('leaves the source row to the source’s own releases and keeps the host’s counters', async () => { + await resetFleet() + const { identity, first } = await seedActiveHostOnIsolatedSource(910) + const sourceBefore = await reservedRequests(ISOLATED.id) + const targetsBefore = + (await reservedRequests(TARGETS[0]!.id)) + (await reservedRequests(TARGETS[1]!.id)) + expect(sourceBefore).toBe(3) + expect(await counters(identity)).toEqual({ controls: 1, splices: 1 }) + + const moved = await stores[1]!.assign(identity, 'us-central1') + expect(TARGETS.map(({ id }) => id)).toContain(moved.cellId) + expect(moved.assignmentEpoch).toBe(first.assignmentEpoch + 1) + expect(await reservedRequests(ISOLATED.id)).toBe(sourceBefore) + expect( + (await reservedRequests(TARGETS[0]!.id)) + (await reservedRequests(TARGETS[1]!.id)) + ).toBe(targetsBefore + 1) + // One control added on the target; the source's control and splice kept. + expect(await counters(identity)).toEqual({ controls: 2, splices: 1 }) + await expectReservationAccounting() + + // The source closes the host's sockets: each release takes its own units. + expect(await stores[0]!.releaseActivity(identity, `control:${ISOLATED.id}:1`)).toBe(true) + expect(await reservedRequests(ISOLATED.id)).toBe(sourceBefore - 1) + expect(await counters(identity)).toEqual({ controls: 1, splices: 1 }) + expect(await stores[0]!.releaseActivity(identity, 'splice:keep-1')).toBe(true) + expect(await reservedRequests(ISOLATED.id)).toBe(0) + // The target's control survives the source's releases. + expect(await counters(identity)).toEqual({ controls: 1, splices: 0 }) + await expectReservationAccounting() + }, 30_000) + + it('re-places an isolated incumbent while the source row is held', async () => { + await resetFleet() + const { identity, first } = await seedActiveHostOnIsolatedSource(911) + const sourceBefore = await reservedRequests(ISOLATED.id) + const release = await holdCellRows([ISOLATED.id]) + try { + // Held far past the 500ms bounded wait: a placement that asked for the + // source row could not finish inside this window. + const moved = await Promise.race([ + stores[1]!.assign(identity, 'us-central1'), + new Promise<'blocked'>((resolve) => setTimeout(() => resolve('blocked'), 3_000)) + ]) + expect(moved).not.toBe('blocked') + expect(moved).toMatchObject({ assignmentEpoch: first.assignmentEpoch + 1 }) + } finally { + await release() + } + expect(await reservedRequests(ISOLATED.id)).toBe(sourceBefore) + await expectReservationAccounting() + }, 30_000) + + it('waits for the target rows it increments', async () => { + await resetFleet() + const { identity, first } = await seedActiveHostOnIsolatedSource(912) + const targetsBefore = + (await reservedRequests(TARGETS[0]!.id)) + (await reservedRequests(TARGETS[1]!.id)) + const release = await holdCellRows(TARGETS.map(({ id }) => id)) + let settled = false + const placement = stores[1]!.assign(identity, 'us-central1').finally(() => { + settled = true + }) + await new Promise((resolve) => setTimeout(resolve, 1_000)) + expect(settled).toBe(false) + await release() + expect(await placement).toMatchObject({ assignmentEpoch: first.assignmentEpoch + 1 }) + expect( + (await reservedRequests(TARGETS[0]!.id)) + (await reservedRequests(TARGETS[1]!.id)) + ).toBe(targetsBefore + 1) + await expectReservationAccounting() + }, 30_000) + + it('keeps the pin when no general cell in any region has headroom', async () => { + await resetFleet() + const { identity, first } = await seedActiveHostOnIsolatedSource(913) + const sourceBefore = await reservedRequests(ISOLATED.id) + await databases[0]!.query( + `UPDATE relay_cells SET reserved_requests = capacity_requests WHERE cell_id IN (?, ?, ?)`, + [...TARGETS, OTHER_REGION].map(({ id }) => id) + ) + try { + expect(await stores[1]!.assign(identity, 'us-central1')).toMatchObject({ + cellId: ISOLATED.id, + assignmentEpoch: first.assignmentEpoch + }) + expect(await reservedRequests(ISOLATED.id)).toBe(sourceBefore) + expect(await counters(identity)).toEqual({ controls: 1, splices: 1 }) + } finally { + await databases[0]!.query( + `UPDATE relay_cells SET reserved_requests = 0 WHERE cell_id IN (?, ?, ?)`, + [...TARGETS, OTHER_REGION].map(({ id }) => id) + ) + } + }, 30_000) + + it('moves to another region when its own is at the connection cap, without the source row', async () => { + await resetFleet() + const { identity, first } = await seedActiveHostOnIsolatedSource(917) + const sourceBefore = await reservedRequests(ISOLATED.id) + await heartbeatAll(Object.fromEntries(TARGETS.map(({ id }) => [id, 600]))) + const release = await holdCellRows([ISOLATED.id]) + try { + const moved = await Promise.race([ + stores[1]!.assign(identity, 'us-central1'), + new Promise<'blocked'>((resolve) => setTimeout(() => resolve('blocked'), 3_000)) + ]) + expect(moved).toMatchObject({ + cellId: OTHER_REGION.id, + region: 'asia-east2', + assignmentEpoch: first.assignmentEpoch + 1 + }) + } finally { + await release() + await heartbeatAll() + } + expect(await reservedRequests(ISOLATED.id)).toBe(sourceBefore) + expect(await reservedRequests(OTHER_REGION.id)).toBe(1) + await expectReservationAccounting() + }, 30_000) + it('places a dormant host without asking for its old cell row', async () => { + await resetFleet() + const identity = hostIdentity(914) + await applySelector({ [TARGETS[0]!.id]: 'migration-only', [TARGETS[1]!.id]: 'migration-only' }) + const first = await stores[0]!.assign(identity, 'us-central1') + expect(first.cellId).toBe(ISOLATED.id) + // Dormant: no units held and no activity inside the dormancy window. + await databases[0]!.query( + `DELETE FROM relay_assignment_activity_leases WHERE user_id = ? AND relay_host_id = ?`, + [identity.userId, identity.relayHostId] + ) + await databases[0]!.query( + `UPDATE relay_assignments SET reserved_controls = 0, last_activity_at = ? + WHERE user_id = ? AND relay_host_id = ?`, + [NOW - ASSIGNMENT_LIMITS.dormantTtlMs, identity.userId, identity.relayHostId] + ) + await databases[0]!.query(`UPDATE relay_cells SET reserved_requests = 0 WHERE cell_id = ?`, [ + ISOLATED.id + ]) + await applySelector({ [TARGETS[0]!.id]: 'general', [TARGETS[1]!.id]: 'general' }) + await applySelector({ [ISOLATED.id]: 'migration-only' }, [ISOLATED.id]) + + const release = await holdCellRows([ISOLATED.id]) + try { + const moved = await Promise.race([ + stores[1]!.assign(identity, 'us-central1'), + new Promise<'blocked'>((resolve) => setTimeout(() => resolve('blocked'), 3_000)) + ]) + expect(moved).toMatchObject({ assignmentEpoch: first.assignmentEpoch + 1 }) + expect(TARGETS.map(({ id }) => id)).toContain(moved === 'blocked' ? moved : moved.cellId) + } finally { + await release() + } + await expectReservationAccounting() + }, 30_000) + // Moves a seeded host onto TARGETS[0] next to another host whose splice there + // outlives the test, so an over-charge shows instead of clamping at zero. + async function seedMovedHostBesideNeighbour(index: number): Promise<{ + identity: { userId: string; relayHostId: string } + neighbour: { userId: string; relayHostId: string } + }> { + const { identity } = await seedActiveHostOnIsolatedSource(index) + await applySelector({ [TARGETS[1]!.id]: 'migration-only' }) + const neighbour = hostIdentity(index + 50) + expect((await stores[0]!.assign(neighbour, 'us-central1')).cellId).toBe(TARGETS[0]!.id) + await stores[0]!.acquireActivity(neighbour, { + activityId: 'splice:neighbour', + kind: 'splice', + cellId: TARGETS[0]!.id, + expiresAt: NOW + 10 * 60 * 60_000 + }) + expect((await stores[1]!.assign(identity, 'us-central1')).cellId).toBe(TARGETS[0]!.id) + expect(await counters(identity)).toEqual({ controls: 2, splices: 1 }) + await expectReservationAccounting() + return { identity, neighbour } + } + + it('leaves a host with leases on two cells to the lease sweep', async () => { + await resetFleet() + const { identity } = await seedMovedHostBesideNeighbour(915) + // Past every lease but the neighbour's splice. + const later = new RelayAssignmentStore(databases[0]!, () => NOW + 60 * 60_000, { + requireLiveCells: true, + heartbeatTtlMs: 45_000 + }) + try { + // Aggregate expiry first, with the source's leases still present: the + // order a lease sweep skipped on a busy row leaves behind. + await later.releaseExpiredActivity() + expect(await counters(identity)).toEqual({ controls: 2, splices: 1 }) + await expectReservationAccounting() + await later.releaseExpiredActivityLeases() + await later.releaseExpiredActivity() + expect(await counters(identity)).toEqual({ controls: 0, splices: 0 }) + expect(await reservedRequests(ISOLATED.id)).toBe(0) + // Only the neighbour's splice is left on the target. + expect(await reservedRequests(TARGETS[0]!.id)).toBe(2) + await expectReservationAccounting() + } finally { + await applySelector({ [TARGETS[1]!.id]: 'general' }) + } + }, 30_000) + + it('frees the source leases on their own cell when the target dies', async () => { + await resetFleet() + const { identity } = await seedMovedHostBesideNeighbour(916) + // The roll finishes, then the target stops heartbeating before the source + // has released the host. Uncapped, so no fence is needed to move off it. + await applySelector({ [ISOLATED.id]: 'general' }) + await databases[0]!.query(`UPDATE relay_cell_runtime SET ready = 0 WHERE cell_id = ?`, [ + TARGETS[0]!.id + ]) + await databases[0]!.query(`DELETE FROM relay_cell_connection_limits WHERE cell_id = ?`, [ + TARGETS[0]!.id + ]) + try { + const moved = await stores[1]!.assign(identity, 'us-central1') + expect(moved.cellId).toBe(ISOLATED.id) + expect(await counters(identity)).toEqual({ controls: 1, splices: 0 }) + // The deleted source leases took their units with them; a late release + // from the source finds nothing and charges nothing. + expect(await stores[0]!.releaseActivity(identity, `control:${ISOLATED.id}:1`)).toBe(false) + expect(await reservedRequests(ISOLATED.id)).toBe(1) + expect(await reservedRequests(TARGETS[0]!.id)).toBe(3) + await expectReservationAccounting() + } finally { + // Re-registers the connection limit, then the heartbeat restores ready. + await stores[0]!.reconcileCells(CELLS, false) + await heartbeatAll() + await applySelector({ [TARGETS[1]!.id]: 'general' }) + } + }, 30_000) + // A host main's old placement left behind: its counters were reset while its + // source lease stayed, and the lease's release floored them at zero. + async function seedDriftedHost( + index: number, + counted: { controls: number; unbackedSourceUnits: number } + ): Promise<{ identity: { userId: string; relayHostId: string }; epoch: number }> { + const identity = hostIdentity(index) + await applySelector({ [TARGETS[0]!.id]: 'migration-only', [TARGETS[1]!.id]: 'migration-only' }) + const first = await stores[0]!.assign(identity, 'us-central1') + expect(first.cellId).toBe(ISOLATED.id) + await stores[0]!.activateControl(identity, { + cellId: ISOLATED.id, + assignmentEpoch: first.assignmentEpoch, + generation: 1 + }) + await databases[0]!.query( + `UPDATE relay_assignments SET reserved_controls = ? WHERE user_id = ? AND relay_host_id = ?`, + [counted.controls, identity.userId, identity.relayHostId] + ) + // Units the source still counts with no lease behind them. + await databases[0]!.query( + `UPDATE relay_cells SET reserved_requests = reserved_requests + ? WHERE cell_id = ?`, + [counted.unbackedSourceUnits, ISOLATED.id] + ) + await applySelector({ [TARGETS[0]!.id]: 'general', [TARGETS[1]!.id]: 'general' }) + await applySelector({ [ISOLATED.id]: 'migration-only' }, [ISOLATED.id]) + return { identity, epoch: first.assignmentEpoch } + } + + it('heals counters below the leases on the narrowed path', async () => { + await resetFleet() + const { identity, epoch } = await seedDriftedHost(918, { controls: 0, unbackedSourceUnits: 0 }) + expect(await counters(identity)).toEqual({ controls: 0, splices: 0 }) + await expectReservationAccounting() + const release = await holdCellRows([ISOLATED.id]) + try { + const moved = await Promise.race([ + stores[1]!.assign(identity, 'us-central1'), + new Promise<'blocked'>((resolve) => setTimeout(() => resolve('blocked'), 3_000)) + ]) + expect(moved).toMatchObject({ assignmentEpoch: epoch + 1 }) + expect(TARGETS.map(({ id }) => id)).toContain(moved === 'blocked' ? moved : moved.cellId) + } finally { + await release() + } + // The kept source control plus the new one. + expect(await counters(identity)).toEqual({ controls: 2, splices: 0 }) + await expectReservationAccounting() + expect(await stores[0]!.releaseActivity(identity, `control:${ISOLATED.id}:1`)).toBe(true) + expect(await counters(identity)).toEqual({ controls: 1, splices: 0 }) + expect(await reservedRequests(ISOLATED.id)).toBe(0) + await expectReservationAccounting() + }, 30_000) + + it('takes the all-rows path for units no lease backs', async () => { + await resetFleet() + const { identity, epoch } = await seedDriftedHost(919, { controls: 3, unbackedSourceUnits: 2 }) + await expectReservationAccountingExcept(ISOLATED.id) + const release = await holdCellRows([ISOLATED.id]) + const held = stores[1]!.assign(identity, 'us-central1').then( + () => 'placed' as const, + () => 'failed' as const + ) + try { + // It needs the source row to take the unbacked units off it, so while the + // row is held it either waits or times out; it never places. + expect( + await Promise.race([ + held, + new Promise<'waiting'>((resolve) => setTimeout(() => resolve('waiting'), 1_000)) + ]) + ).not.toBe('placed') + } finally { + await release() + } + await held + // Whether the held dial finished after the release or failed, the next one + // lands the single move. + expect(await stores[1]!.assign(identity, 'us-central1')).toMatchObject({ + assignmentEpoch: epoch + 1 + }) + expect(await counters(identity)).toEqual({ controls: 2, splices: 0 }) + await expectReservationAccounting() + }, 30_000) + it('refuses at once while the host’s own release holds its assignment row', async () => { + await resetFleet() + const { identity, epoch } = await seedDriftedHost(921, { controls: 1, unbackedSourceUnits: 0 }) + const release = await holdRows( + `SELECT user_id FROM relay_assignments WHERE user_id = ? AND relay_host_id = ?`, + [identity.userId, identity.relayHostId] + ) + try { + const startedAt = performance.now() + await expect(stores[1]!.assign(identity, 'us-central1')).rejects.toBeInstanceOf( + RelayAssignmentRowBusyError + ) + // An isolated pin gets no wait: far under the 1s a release can hold it. + expect(performance.now() - startedAt).toBeLessThan(500) + } finally { + await release() + } + expect((await stores[0]!.resolve(identity))?.assignmentEpoch).toBe(epoch) + expect((await stores[1]!.assign(identity, 'us-central1')).assignmentEpoch).toBe(epoch + 1) + await expectReservationAccounting() + }, 30_000) + + it('waits up to a second for the row of a host pinned to a live general cell', async () => { + await resetFleet() + const identity = hostIdentity(922) + const first = await stores[0]!.assign(identity, 'us-central1') + const holdAssignment = async (): Promise<() => Promise> => + await holdRows( + `SELECT user_id FROM relay_assignments WHERE user_id = ? AND relay_host_id = ?`, + [identity.userId, identity.relayHostId] + ) + + // A short hold, like a calm host's own release: the sticky re-grant waits. + const shortHold = await holdAssignment() + const regrant = stores[1]!.assign(identity, 'us-central1') + await new Promise((resolve) => setTimeout(resolve, 200)) + await shortHold() + expect(await regrant).toMatchObject({ + cellId: first.cellId, + assignmentEpoch: first.assignmentEpoch + }) + + // Held past the bound: refused, after about the bound and not the pool's. + const longHold = await holdAssignment() + try { + const startedAt = performance.now() + await expect(stores[1]!.assign(identity, 'us-central1')).rejects.toBeInstanceOf( + RelayAssignmentRowBusyError + ) + const waitedMs = performance.now() - startedAt + expect(waitedMs).toBeGreaterThanOrEqual(900) + expect(waitedMs).toBeLessThan(2_000) + } finally { + await longHold() + } + }, 30_000) + it('never waits on the assignment row while its retry holds the pinned cell row', async () => { + await resetFleet() + const identity = hostIdentity(923) + const first = await stores[0]!.assign(identity, 'us-central1') + // No control lease, so the sticky re-grant needs the cell row itself. + expect( + await stores[0]!.releaseActivity(identity, `control-pending:${first.assignmentEpoch}`) + ).toBe(true) + // The first attempt finds the cell row busy and retries cell row first. + const releaseCell = await holdCellRows([first.cellId]) + const regrant = stores[1]!.assign(identity, 'us-central1').then( + () => 'granted', + (error: unknown) => error + ) + await new Promise((resolve) => setTimeout(resolve, 150)) + // The row the retry reaches next, held the way this host's release holds it. + const releaseAssignment = await holdRows( + `SELECT user_id FROM relay_assignments WHERE user_id = ? AND relay_host_id = ?`, + [identity.userId, identity.relayHostId] + ) + try { + await releaseCell() + const cellFreedAt = performance.now() + expect(await regrant).toBeInstanceOf(RelayAssignmentRowBusyError) + // NOWAIT once the cell row is held: nowhere near the 1s bounded wait. + expect(performance.now() - cellFreedAt).toBeLessThan(300) + } finally { + await releaseAssignment() + } + }, 30_000) }) diff --git a/cloud/apps/relay/src/assignment-isolated-cell-replacement.test.ts b/cloud/apps/relay/src/assignment-isolated-cell-replacement.test.ts index cc1ae6a26bb0..19d2f357b023 100644 --- a/cloud/apps/relay/src/assignment-isolated-cell-replacement.test.ts +++ b/cloud/apps/relay/src/assignment-isolated-cell-replacement.test.ts @@ -54,6 +54,9 @@ class QueryCountingDatabase implements RelayDatabase { // Fails the first statement containing this fragment, so a test can roll a // transaction back at a chosen point. failOnce: string | undefined + // Locked statements that report the row busy, in order: each fragment fires + // once, on the first locked statement containing it after the one before. + lockUnavailableSequence: string[] = [] constructor(private readonly delegate: RelayDatabase) {} @@ -83,10 +86,19 @@ class QueryCountingDatabase implements RelayDatabase { params?: unknown[], options?: RelayLockOptions ): Promise { - this.record(sql) + this.recordLocked(sql) return await this.delegate.queryLocked(sql, params, options) } + recordLocked(sql: string): void { + this.record(sql) + const next = this.lockUnavailableSequence[0] + if (next !== undefined && sql.includes(next)) { + this.lockUnavailableSequence.shift() + throw new Error('database_lock_unavailable') + } + } + async transaction( operation: (transaction: RelayDatabase) => Promise, options?: RelayTransactionOptions @@ -111,7 +123,7 @@ class QueryCountingDatabase implements RelayDatabase { return await inner.query(sql, params) }, queryLocked: async (sql, params, options) => { - this.record(sql) + this.recordLocked(sql) return await inner.queryLocked(sql, params, options) }, transaction: async (operation, options) => await inner.transaction(operation, options), @@ -463,9 +475,7 @@ describe('re-placing a host off a cell isolated for a roll', () => { }) }) - it('never re-places across a region boundary, and says so', async () => { - // Both US cells parked and only Asia general: ordinary placement would spill - // to asia-east2 through `preferred[0] ?? candidates[0]`. This path refuses. + it('crosses a region boundary only when its own region has no room', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const { store, isolateForRoll, park } = await setup() const first = await store.assign(IDENTITY, 'us-central1') @@ -473,6 +483,32 @@ describe('re-placing a host off a cell isolated for a roll', () => { await park(other.id, 'migration-only') await isolateForRoll(first.cellId) + warn.mockClear() + expect(await store.assign(IDENTITY, 'us-central1')).toMatchObject({ + cellId: 'asia-c1', + assignmentEpoch: first.assignmentEpoch + 1 + }) + expect(jsonEvents(warn, 'orca_relay_sticky_replaced_off_isolated_cell')).toEqual([ + { + event: 'orca_relay_sticky_replaced_off_isolated_cell', + fromCellId: first.cellId, + fromRegion: 'us-central1', + admissionState: 'migration-only', + toCellId: 'asia-c1', + region: 'asia-east2' + } + ]) + }) + + it('keeps the pin, and says so, when no region has a general cell with room', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { store, isolateForRoll, park } = await setup() + const first = await store.assign(IDENTITY, 'us-central1') + for (const cell of CELLS) { + if (cell.id !== first.cellId) await park(cell.id, 'migration-only') + } + await isolateForRoll(first.cellId) + warn.mockClear() expect(await store.assign(IDENTITY, 'us-central1')).toMatchObject({ cellId: first.cellId, @@ -489,17 +525,25 @@ describe('re-placing a host off a cell isolated for a roll', () => { expect(jsonEvents(warn, 'orca_relay_sticky_replaced_off_isolated_cell')).toEqual([]) }) - it('keeps a re-placed host in its own region', async () => { + it('prefers its own region over a less loaded cell elsewhere', async () => { + const { store, isolateForRoll } = await setup() + const first = await store.assign(IDENTITY, 'us-central1') + await isolateForRoll(first.cellId) + const moved = await store.assign(IDENTITY, 'us-central1') + expect(moved.region).toBe('us-central1') + expect(moved.cellId).not.toBe(first.cellId) + }) + + it('moves the only cell of a region to another region', async () => { const { store, isolateForRoll } = await setup() const first = await store.assign(IDENTITY, 'asia-east2') expect(first.region).toBe('asia-east2') await isolateForRoll(first.cellId) // asia-c1 is the only Asia cell, so the only in-region candidate is gone. - expect(await store.assign(IDENTITY, 'asia-east2')).toMatchObject({ - cellId: first.cellId, - assignmentEpoch: first.assignmentEpoch - }) + const moved = await store.assign(IDENTITY, 'asia-east2') + expect(moved.region).toBe('us-central1') + expect(moved.assignmentEpoch).toBe(first.assignmentEpoch + 1) }) it('takes the dead-cell path when a stamped cell stops heartbeating', async () => { @@ -606,4 +650,117 @@ describe('re-placing a host off a cell isolated for a roll', () => { } ]) }) + describe('a host whose leases span two cells after the move', () => { + const NEIGHBOUR = { userId: 'user-b', relayHostId: 'host000000000002' } + + async function reserved(database: RelayDatabase, cellId: string): Promise { + const rows = await database.query( + `SELECT reserved_requests FROM relay_cells WHERE cell_id = ?`, + [cellId] + ) + return Number(rows[0]!['reserved_requests']) + } + + async function expectAccounting(database: RelayDatabase): Promise { + for (const cell of CELLS) { + const leased = await database.query( + `SELECT COALESCE(SUM(request_units), 0) AS units + FROM relay_assignment_activity_leases WHERE cell_id = ?`, + [cell.id] + ) + expect({ cell: cell.id, reserved: await reserved(database, cell.id) }).toEqual({ + cell: cell.id, + reserved: Number(leased[0]!['units']) + }) + } + } + + async function counters(database: RelayDatabase): Promise { + const rows = await database.query( + `SELECT reserved_controls, reserved_splices FROM relay_assignments + WHERE user_id = ? AND relay_host_id = ?`, + [IDENTITY.userId, IDENTITY.relayHostId] + ) + return [Number(rows[0]!['reserved_controls']), Number(rows[0]!['reserved_splices'])] + } + + // us-c1 is isolated with the host's control and splice on it; the host + // moves to us-c2, where a neighbour's splice outlives every other lease. + async function movedHost(): Promise { + const harness = await setup() + const { store, isolateForRoll } = harness + const first = await store.assign(IDENTITY, 'us-central1') + expect(first.cellId).toBe('us-c1') + await store.activateControl(IDENTITY, { + cellId: 'us-c1', + assignmentEpoch: first.assignmentEpoch, + generation: 1 + }) + await store.acquireActivity(IDENTITY, { + activityId: 'splice:keep-1', + kind: 'splice', + cellId: 'us-c1' + }) + await isolateForRoll('us-c1') + expect((await store.assign(NEIGHBOUR, 'us-central1')).cellId).toBe('us-c2') + await store.acquireActivity(NEIGHBOUR, { + activityId: 'splice:neighbour', + kind: 'splice', + cellId: 'us-c2', + expiresAt: START_MS + 10 * 60 * 60_000 + }) + expect((await store.assign(IDENTITY, 'us-central1')).cellId).toBe('us-c2') + expect(await counters(harness.database)).toEqual([2, 1]) + await expectAccounting(harness.database) + return harness + } + + it('is left to the lease sweep by aggregate expiry', async () => { + const { store, database, setNow } = await movedHost() + setNow(START_MS + 60 * 60_000) + await store.releaseExpiredActivity() + expect(await counters(database)).toEqual([2, 1]) + await expectAccounting(database) + await store.releaseExpiredActivityLeases() + await store.releaseExpiredActivity() + expect(await counters(database)).toEqual([0, 0]) + expect(await reserved(database, 'us-c1')).toBe(0) + expect(await reserved(database, 'us-c2')).toBe(2) + await expectAccounting(database) + }) + + it('frees the source leases on their own cell when the target dies', async () => { + const { store, database, setNow, heartbeat, restore } = await movedHost() + await restore('us-c1') + // Uncapped, so the dead-cell path needs no fence to move off it. + await database.query(`DELETE FROM relay_cell_connection_limits WHERE cell_id = 'us-c2'`) + setNow(START_MS + HEARTBEAT_TTL_MS + 5_000) + await heartbeat(CELLS[0]!) + await heartbeat(CELLS[2]!) + + expect((await store.assign(IDENTITY, 'us-central1')).cellId).toBe('us-c1') + expect(await counters(database)).toEqual([1, 0]) + expect(await store.releaseActivity(IDENTITY, 'control:us-c1:1')).toBe(false) + expect(await reserved(database, 'us-c1')).toBe(1) + expect(await reserved(database, 'us-c2')).toBe(3) + await expectAccounting(database) + }) + }) + it('retries a busy isolated attempt in its own tier, never under the all-rows lock', async () => { + const { store, counter, isolateForRoll } = await setup() + const first = await store.assign(IDENTITY, 'us-central1') + await isolateForRoll(first.cellId) + counter.sql.length = 0 + // The first attempt finds a target row busy, then its retry finds the host's + // lease rows busy after it has already taken the tier's rows. + counter.lockUnavailableSequence = [ + 'SELECT * FROM relay_cells WHERE cell_id IN (', + 'FROM relay_assignment_activity_leases' + ] + + const moved = await store.assign(IDENTITY, 'us-central1') + expect(counter.lockUnavailableSequence).toEqual([]) + expect(moved).toMatchObject({ region: 'us-central1', assignmentEpoch: first.assignmentEpoch + 1 }) + expect(counter.count('SELECT * FROM relay_cells ORDER BY cell_id ASC')).toBe(0) + }) }) diff --git a/cloud/apps/relay/src/assignment-store-lock-order.test.ts b/cloud/apps/relay/src/assignment-store-lock-order.test.ts index ea04e016a43f..e6ca46baf710 100644 --- a/cloud/apps/relay/src/assignment-store-lock-order.test.ts +++ b/cloud/apps/relay/src/assignment-store-lock-order.test.ts @@ -79,6 +79,9 @@ class ReassignmentLockOrderDatabase implements RelayDatabase { readonly locks: string[] = [] async query(sql: string): Promise { + if (sql.includes('SELECT cell_id FROM relay_assignments')) { + return [{ cell_id: 'cell-b' }] + } if (sql.includes('SELECT cell_id, region FROM relay_cell_regions')) { return ['cell-a', 'cell-b'].map((cell_id) => ({ cell_id, region: 'us-central1' })) } @@ -239,6 +242,12 @@ class NewAssignmentLockDatabase implements RelayDatabase { ) {} async query(sql: string, params: unknown[] = []): Promise { + // The sticky lane's unlocked pin read sees what the locked read would. + if (sql.includes('SELECT cell_id FROM relay_assignments')) { + return this.assignmentAppearsAfterFailure && this.generalLockFailed + ? [{ cell_id: 'cell-existing' }] + : [] + } if (sql.includes('SELECT cell_id, region FROM relay_cell_regions')) { return [ { cell_id: 'cell-existing', region: 'us-central1' }, @@ -480,7 +489,7 @@ describe('RelayAssignmentStore activity lock order', () => { 'cell-b', 'assignment', 'cell-inventory', - 'cell-b', + // No 'cell-b' write: the host holds no units, so there is nothing to release. 'cell-a' ]) }) diff --git a/cloud/apps/relay/src/assignment-store.test.ts b/cloud/apps/relay/src/assignment-store.test.ts index 6153a2791960..9b889a133a88 100644 --- a/cloud/apps/relay/src/assignment-store.test.ts +++ b/cloud/apps/relay/src/assignment-store.test.ts @@ -2,7 +2,11 @@ import { ASSIGNMENT_LIMITS } from '@orca-cloud/relay-contract' import { createHash } from 'node:crypto' import { afterEach, describe, expect, it, vi } from 'vitest' import type { RelayCellConfig } from './config.js' -import { RelayAssignmentStore, STRANDED_MIGRATION_ABANDON_MS } from './assignment-store.js' +import { + RelayAssignmentRowBusyError, + RelayAssignmentStore, + STRANDED_MIGRATION_ABANDON_MS +} from './assignment-store.js' import { encodeMembership, type CellAdmissionMembership @@ -1658,6 +1662,39 @@ describe('RelayAssignmentStore', () => { ).toEqual([{ activity_kind: 'control', cell_id: 'cell-b' }]) }) + it('skips a host whose row is busy and still evacuates the rest of the dead cell', async () => { + let now = 100 + const cells: RelayCellConfig[] = [ + { id: 'cell-a', url: 'https://relay-a.example.com', capacityRequests: 10 }, + { id: 'cell-b', url: 'https://relay-b.example.com', capacityRequests: 10 } + ] + const store = await setupWithHeartbeats(() => now, cells) + for (const cell of cells) await heartbeat(store, cell) + await store.setCellEnabled('cell-b', false) + const busy = { userId: 'a-busy', relayHostId: 'host000000000001' } + const later = { userId: 'b-later', relayHostId: 'host000000000002' } + expect(await store.assign(busy)).toMatchObject({ cellId: 'cell-a' }) + expect(await store.assign(later)).toMatchObject({ cellId: 'cell-a' }) + await store.setCellEnabled('cell-b', true) + + now += 45_001 + await heartbeat(store, cells[1]!) + // The sweep reaches the busy host first: its own release holds its row. + const assign = store.assign.bind(store) + vi.spyOn(store, 'assign').mockImplementation(async (identity, ...rest) => { + if (identity.relayHostId === busy.relayHostId) throw new RelayAssignmentRowBusyError() + return await assign(identity, ...rest) + }) + + expect(await store.evacuateDeadCells()).toBe(1) + expect(await store.resolve(later)).toMatchObject({ cellId: 'cell-b' }) + expect( + await database!.query(`SELECT cell_id FROM relay_assignments WHERE user_id = ?`, [ + busy.userId + ]) + ).toEqual([{ cell_id: 'cell-a' }]) + }) + it('does not let an unfenced capped cell starve eligible legacy recovery', async () => { let now = 100 const cells: RelayCellConfig[] = [ diff --git a/cloud/apps/relay/src/assignment-store.ts b/cloud/apps/relay/src/assignment-store.ts index 9a5cd9d4165c..ba4131dbf796 100644 --- a/cloud/apps/relay/src/assignment-store.ts +++ b/cloud/apps/relay/src/assignment-store.ts @@ -350,6 +350,8 @@ const ACTIVITY_REQUEST_UNITS: Record = { } const ASSIGNMENT_LOCK_RETRY_DEADLINE_MS = 15_000 +// About one release round trip from Asia, with margin; see assignStickyOnce. +const STICKY_ASSIGNMENT_ROW_WAIT_MS = 1_000 // THE ROW LOCK ORDER. Every transaction that takes more than one of these // takes them in this order, whichever role it runs on: @@ -426,7 +428,16 @@ const REGIONAL_REHOME_QUARANTINE_MEMORY_LIMIT = 1_000 const REGIONAL_REHOME_FAILURE_BUDGET = 3 const REGIONAL_REHOME_OBSERVATION_MS = 24 * 60 * 60_000 const ASSIGNMENT_LOCK_RETRY_MAX_DELAY_MS = 50 -type AssignmentInventoryScope = 'none' | 'general' | 'all' +// 'isolated' and 'isolated-other-regions': the general rows a roll-isolated +// incumbent can move to, its own region first, never its own row. The source +// row is the one a drain's releases keep busy. +type AssignmentInventoryScope = + | 'none' + | 'general' + | 'isolated' + | 'isolated-other-regions' + | 'all' +type IsolatedReplacementTier = 'same-region' | 'other-regions' type RetriedAssignmentInventoryScope = Exclude class AssignmentInventoryLockUnavailable extends Error { @@ -435,6 +446,13 @@ class AssignmentInventoryLockUnavailable extends Error { } } +// Postgres holds row locks to COMMIT, so the next tier needs a new transaction. +class AssignmentIsolatedTierExhausted extends Error { + constructor() { + super('assignment_isolated_tier_exhausted') + } +} + class AssignmentInventoryScopeChanged extends Error { constructor() { super('assignment_inventory_scope_changed') @@ -451,6 +469,16 @@ export type RelayHomeCellUnavailableCause = | 'unheard' | 'not_ready' +// The host's own row is held, almost always by that host's release on its old +// cell, which sits queued on that cell's busy row. Waiting here would hold the +// director's single sticky slot for the length of that queue, so the dial is +// refused at once and the host redials after the release has settled. +export class RelayAssignmentRowBusyError extends Error { + constructor() { + super('relay_assignment_row_busy') + } +} + export class RelayHomeCellUnavailableError extends Error { constructor( readonly cellId: string, @@ -702,6 +730,10 @@ export class RelayAssignmentStore { inventoryScope = 'all' continue } + if (error instanceof AssignmentIsolatedTierExhausted) { + inventoryScope = 'isolated-other-regions' + continue + } if ( !(error instanceof AssignmentInventoryLockUnavailable) || Date.now() >= deadline @@ -743,24 +775,41 @@ export class RelayAssignmentStore { // order placement uses. It only ever needs the one cell this host is // pinned to, so read the pin unlocked and lock that row alone; taking all // 23 queued every sticky refresh in the fleet behind every other one. - const pinnedCellId = inventoryFirst - ? await this.pinnedCellId(transaction, identity) - : undefined + const pinnedCellId = await this.pinnedCellId(transaction, identity) const lockedCells = - pinnedCellId === undefined + !inventoryFirst || pinnedCellId === undefined ? undefined : await this.lockCellRows(transaction, [pinnedCellId], lockMode) - const existing = await this.assignmentRow(transaction, identity, inventoryFirst) + // One read serves the wait policy, the stranded rule and the roll-isolation + // check below; issuing it twice inside one sticky transaction is waste. + const unlockedAdmission = + pinnedCellId === undefined + ? undefined + : await this.pinnedCellAdmission(transaction, pinnedCellId) + // Why wait at all: a calm host redialling after its own clean close meets + // its own release, and a refusal costs it the client's 5 s assign gate. On + // a live general cell that release is short, so a bounded wait is cheap. + // A roll, drain or dead cell keeps its release queued, so refuse at once. + // Never while holding the cell row: that reverses the lock order against + // this host's own release, which holds its row and wants the cell's. + const pinIsLiveGeneral = + lockedCells === undefined && + pinnedCellId !== undefined && + unlockedAdmission?.state === 'general' && + (await this.cellIsLive(transaction, pinnedCellId, now)) + const existing = await this.assignmentRowOrBusy( + transaction, + identity, + pinIsLiveGeneral ? STICKY_ASSIGNMENT_ROW_WAIT_MS : undefined + ) if (!existing) return null const activityLeases = await this.lockAssignmentActivities(transaction, identity, true) await this.recordRegionPreference(transaction, identity, preferredRegion, now) if (mayNormallyReassign(activity(existing), now)) return null - // One read serves the stranded rule and the roll-isolation check below; - // issuing it twice inside one sticky transaction is pure waste. - const pinnedAdmission = await this.pinnedCellAdmission( - transaction, - text(existing, 'cell_id') - ) + const pinnedAdmission = + text(existing, 'cell_id') === pinnedCellId + ? unlockedAdmission + : await this.pinnedCellAdmission(transaction, text(existing, 'cell_id')) // Why: a stranded host must fall through to placement — re-granting the // pinned cell here is what refreshes its own activity and sustains the // loop (issue #225). @@ -776,10 +825,29 @@ export class RelayAssignmentStore { return null } + // Why: a null here means "fall through to placement", which is exactly + // what an isolated incumbent needs — and the only way out, because + // re-granting the pin refreshes the host's own activity and sustains the + // loop. The placement lane re-places it on a cell that will take it. + // Decided before the pinned cell row is touched: during a drain that row + // is the one the cell's releases keep busy. + if ( + await this.incumbentCellIsolatedForRoll( + transaction, + identity, + existing, + now, + undefined, + pinnedAdmission + ) + ) { + return null + } + const currentCellId = text(existing, 'cell_id') // The pin moved between the unlocked read and the assignment lock, so the // row held is the wrong one. Same recovery as losing the lock: retry. - if (pinnedCellId !== undefined && pinnedCellId !== currentCellId) { + if (lockedCells && pinnedCellId !== currentCellId) { throw new Error('database_lock_unavailable') } const hadControl = holdsControlLease( @@ -809,23 +877,6 @@ export class RelayAssignmentStore { ) { return null } - // Why: a null here means "fall through to placement", which is exactly - // what an isolated incumbent needs — and the only way out, because - // re-granting the pin refreshes the host's own activity and sustains the - // loop. The placement lane re-places it on a cell that will take it. - if ( - await this.incumbentCellIsolatedForRoll( - transaction, - identity, - existing, - now, - undefined, - pinnedAdmission - ) - ) { - return null - } - if ( !hadControl && !(await this.cellHasConnectionHeadroom(transaction, currentCellId)) @@ -924,8 +975,10 @@ export class RelayAssignmentStore { placementRegion: RelayRegion = preferredRegion ?? RELAY_DEFAULT_REGION ): Promise { const now = this.now() + // An isolated retry takes its tier's rows before anything reassigns this, + // so a lock timeout there must retry the same tier, not fall to 'all'. let retryScope: RetriedAssignmentInventoryScope = - inventoryScope === 'all' ? 'all' : 'general' + inventoryScope === 'none' ? 'general' : inventoryScope // Why the events ride back out rather than being written where they are // decided: everything below runs in one transaction, and a reservation or // lease write that fails after the decision rolls the placement back. A @@ -938,19 +991,35 @@ export class RelayAssignmentStore { // The retry paths below open with the inventory, so this path takes its // host rows before any of them rather than where the others do. await this.lockControlConnectionReservations(transaction, identity, lockMode) + const isolatedTier: IsolatedReplacementTier = + inventoryScope === 'isolated-other-regions' ? 'other-regions' : 'same-region' + const isolatedScope = inventoryScope === 'isolated' || inventoryScope === 'isolated-other-regions' + const isolatedRetrySource = isolatedScope + ? await this.pinnedCellId(transaction, identity) + : undefined let lockedCells = inventoryScope === 'all' ? await this.lockCellInventory(transaction, lockMode) : inventoryScope === 'general' ? await this.lockGeneralCellInventory(transaction, lockMode) - : undefined - const existing = await this.assignmentRow( - transaction, - identity, - inventoryScope !== 'none' - ) - retryScope = existing ? 'all' : 'general' - if (inventoryScope === 'general' && existing) { + : isolatedScope + ? await this.lockIsolatedReplacementCells( + transaction, + isolatedRetrySource, + isolatedTier, + lockMode + ) + : undefined + const existing = await this.assignmentRowOrBusy(transaction, identity) + // A dormant host holds no units, so its placement never writes its old + // cell and needs only the rows it could land on. + const dormant = !existing || mayNormallyReassign(activity(existing), now) + retryScope = dormant ? 'general' : isolatedScope ? inventoryScope : 'all' + if ( + (inventoryScope === 'general' && !dormant) || + (isolatedScope && + (!existing || dormant || text(existing, 'cell_id') !== isolatedRetrySource)) + ) { throw new AssignmentInventoryScopeChanged() } const activityLeases = await this.lockAssignmentActivities(transaction, identity, true) @@ -960,7 +1029,46 @@ export class RelayAssignmentStore { let strandedReassignment = false let isolatedIncumbent: CellRow | undefined let isolatedTarget: CellRow | undefined - if (existing && !mayNormallyReassign(activity(existing), now)) { + // Set when only the target rows are locked. The old cell's row is never + // written then, so the host must hold no units that no lease backs. + // Counters below the leases (drift the old reset left) are fine: the + // write below rebuilds them from the leases. + let sourceRowSkipped = false + if (existing && !dormant && inventoryScope !== 'all') { + // Unlocked on purpose: this only chooses which rows to lock. A restore + // racing it lets one host move off a cell that no longer needed it. + if ( + requestUnits(existing) <= leaseRequestUnits(activityLeases) && + (await this.isolatedIncumbentMayMove(transaction, identity, existing, now)) + ) { + const source = await this.unlockedCell(transaction, text(existing, 'cell_id')) + retryScope = isolatedTier === 'same-region' ? 'isolated' : 'isolated-other-regions' + lockedCells ??= await this.lockIsolatedReplacementCells( + transaction, + source.cellId, + isolatedTier, + 'nowait' + ) + isolatedTarget = + (await this.leastLoadedCell( + transaction, + lockedCells, + source.region, + isolatedTier === 'same-region' ? 'require' : 'prefer' + )) ?? undefined + // Same region full: try the other regions. All full: decide under the + // all-rows lock, which keeps the pin as before. + if (!isolatedTarget) { + if (isolatedTier === 'same-region') throw new AssignmentIsolatedTierExhausted() + throw new AssignmentInventoryScopeChanged() + } + isolatedIncumbent = source + sourceRowSkipped = true + } else if (isolatedScope) { + throw new AssignmentInventoryScopeChanged() + } + } + if (existing && !dormant && !sourceRowSkipped) { lockedCells ??= await this.lockCellInventory(transaction, 'nowait') const admission = await cellAdmissionStates(transaction) const currentRow = lockedCells.find( @@ -1006,9 +1114,9 @@ export class RelayAssignmentStore { if (isolatedTarget) { isolatedIncumbent = current } else { - // Keeping the pin is today's behaviour: the host keeps retrying its - // own cell. Scattering a region across the fleet is worse, and it - // cannot be undone without the rehome worker. + // Keep the pin: the host retries its own cell. The narrowed path + // has already tried every region, or declined because the host + // holds units no lease backs; the reason string is a log contract. events.push( JSON.stringify({ event: 'orca_relay_sticky_replacement_deferred', @@ -1089,9 +1197,8 @@ export class RelayAssignmentStore { } } - lockedCells ??= existing - ? await this.lockCellInventory(transaction, 'nowait') - : await this.lockGeneralCellInventory(transaction, 'nowait') + // Only a host with no assignment or a dormant one reaches here unlocked. + lockedCells ??= await this.lockGeneralCellInventory(transaction, 'nowait') // The isolated target was chosen under the same inventory lock, with // region required rather than preferred; re-picking here would reopen the // cross-region spill it exists to refuse. @@ -1113,14 +1220,25 @@ export class RelayAssignmentStore { }) ) } - const previousUnits = existing ? requestUnits(existing) : 0 + // Units come off the cell that holds them. A lease this placement deletes + // frees its units on its own cell; a lease it keeps stays counted until + // its own release or expiry. Only units no lease backs are charged to + // the old cell, which is locked whenever there are any. + const deletesLeases = forcedDeadReassignment || strandedReassignment + const keptLeases = deletesLeases ? [] : activityLeases if (existing) { - await this.adjustCellReservation(transaction, text(existing, 'cell_id'), -previousUnits) - if (forcedDeadReassignment || strandedReassignment) { + const unbackedUnits = requestUnits(existing) - leaseRequestUnits(activityLeases) + if (unbackedUnits > 0) { + await this.adjustCellReservation(transaction, text(existing, 'cell_id'), -unbackedUnits) + } + if (deletesLeases) { // A fenced/dead incarnation cannot own drainable work, and a // stranded host's only leases are the unclaimed grant artifacts of // its own loop. Removing them prevents late expiry from // decrementing the replacement. + for (const [cellId, units] of leaseUnitsByCell(activityLeases)) { + await this.adjustCellReservation(transaction, cellId, -units) + } await transaction.query( `DELETE FROM relay_assignment_activity_leases WHERE user_id = ? AND relay_host_id = ?`, @@ -1130,6 +1248,9 @@ export class RelayAssignmentStore { } await this.adjustCellReservation(transaction, target.cellId, 1) const assignmentEpoch = existing ? integer(existing, 'assignment_epoch') + 1 : 1 + // Counters follow the leases the host still holds, plus the new control. + const counts = activityCounts(keptLeases) + counts.control += 1 const leaseExpiresAt = now + ASSIGNMENT_LIMITS.activityLeaseMs await transaction.query( `INSERT INTO relay_assignments @@ -1153,14 +1274,18 @@ export class RelayAssignmentStore { identity.relayHostId, target.cellId, assignmentEpoch, - leaseExpiresAt, + // Not before a kept lease expires: aggregate expiry keys off this. + Math.max( + leaseExpiresAt, + ...keptLeases.map((lease) => integer(lease, 'expires_at')) + ), now, - 1, - 0, - 0, - 0, - 0, - 0 + counts.control, + counts.splice, + counts.invite, + counts.install, + counts.confirmation, + counts.migration ] ) if (existing) { @@ -2856,9 +2981,11 @@ export class RelayAssignmentStore { ) if (assignment.cellId !== text(row, 'cell_id')) moved++ } catch (error) { - // One unplaceable host must not end the sweep for the rest. + // One unplaceable or busy host must not end the sweep for the rest; a + // busy host is mid-release and the next tick takes it. if ( !(error instanceof RelayHomeCellUnavailableError) && + !(error instanceof RelayAssignmentRowBusyError) && !(error instanceof Error && error.message === 'relay_capacity_exhausted') ) { throw error @@ -7075,10 +7202,17 @@ export class RelayAssignmentStore { const now = this.now() try { return await this.database.transaction(async (transaction) => { + // Only counters no lease backs. A host's leases can sit on more than + // one cell, and the lease sweep takes each one's units off its own. const expired = await transaction.queryLocked( `SELECT * FROM relay_assignments WHERE lease_expires_at <= ? AND (reserved_controls > 0 OR reserved_splices > 0 OR reserved_invites > 0 OR pending_installs > 0 OR pending_confirmations > 0 OR migration_leases > 0) + AND NOT EXISTS ( + SELECT 1 FROM relay_assignment_activity_leases lease + WHERE lease.user_id = relay_assignments.user_id + AND lease.relay_host_id = relay_assignments.relay_host_id + ) AND NOT EXISTS ( SELECT 1 FROM relay_region_rehome_attempts rehome WHERE rehome.user_id = relay_assignments.user_id @@ -7344,6 +7478,66 @@ export class RelayAssignmentStore { return rows } + // The rows a roll-isolated incumbent may move to in one tier: general cells + // in its own region, or in every other region, never its own. Placement + // re-reads admission and headroom under the lock. + private async lockIsolatedReplacementCells( + database: RelayDatabase, + sourceCellId: string | undefined, + tier: IsolatedReplacementTier, + mode: CellInventoryLockMode + ): Promise { + if (sourceCellId === undefined) throw new AssignmentInventoryScopeChanged() + const candidates = await database.query( + `SELECT admission.cell_id FROM relay_cell_admission admission + LEFT JOIN relay_cell_regions region ON region.cell_id = admission.cell_id + WHERE admission.admission_state = 'general' AND admission.cell_id <> ? + AND COALESCE(region.region, ?) ${tier === 'same-region' ? '=' : '<>'} ?`, + [sourceCellId, RELAY_DEFAULT_REGION, await this.cellRegion(database, sourceCellId)] + ) + if (candidates.length === 0) { + if (tier === 'same-region') throw new AssignmentIsolatedTierExhausted() + throw new AssignmentInventoryScopeChanged() + } + return await this.lockCellRows( + database, + candidates.map((row) => text(row, 'cell_id')), + mode + ) + } + + // The same questions the all-rows path asks before moving an isolated + // incumbent, none of which needs a relay_cells lock. + private async isolatedIncumbentMayMove( + database: RelayDatabase, + identity: AssignmentIdentity, + existing: SqlRow, + now: number + ): Promise { + const cellId = text(existing, 'cell_id') + const pinned = await this.pinnedCellAdmission(database, cellId) + if (await this.assignmentStrandedOnUnservedCell(database, identity, existing, now, pinned?.state)) { + return false + } + if (this.requireLiveCells && !(await this.cellIsLive(database, cellId, now))) return false + return await this.incumbentCellIsolatedForRoll( + database, + identity, + existing, + now, + undefined, + pinned + ) + } + + private async unlockedCell(database: RelayDatabase, cellId: string): Promise { + const row = ( + await database.query(`SELECT * FROM relay_cells WHERE cell_id = ?`, [cellId]) + )[0] + if (!row) throw new Error('assigned_cell_missing') + return cell(row, await this.cellRegion(database, cellId)) + } + private async leastLoadedCell( database: RelayDatabase, // Required: the one caller has already locked the inventory it selects from, @@ -7351,9 +7545,8 @@ export class RelayAssignmentStore { rows: SqlRow[], preferredRegion: RelayRegion, // Ordinary placement treats region as a preference and spills globally - // rather than refuse a host a cell. Re-placing off an isolated cell is the - // one caller that must not: a whole region parked migration-only would send - // every one of its hosts to the fallback region, permanently. + // rather than refuse a host a cell. Re-placing off an isolated cell asks for + // its own region first, and leaves it only in a separate second tier. regionMode: 'prefer' | 'require' = 'prefer' ): Promise { const regions = new Map( @@ -8015,6 +8208,29 @@ export class RelayAssignmentStore { )[0] } + // NOWAIT unless the caller grants a bounded wait; either way a held row + // surfaces as RelayAssignmentRowBusyError, never as a retried lock timeout. + private async assignmentRowOrBusy( + database: RelayDatabase, + identity: AssignmentIdentity, + waitMs?: number + ): Promise { + try { + return ( + await database.queryLocked( + `SELECT * FROM relay_assignments WHERE user_id = ? AND relay_host_id = ?`, + [identity.userId, identity.relayHostId], + waitMs === undefined ? { failIfUnavailable: true } : { lockTimeoutMs: waitMs } + ) + )[0] + } catch (error) { + if (isDatabaseLockUnavailable(error) || (waitMs !== undefined && isDatabaseLockTimeout(error))) { + throw new RelayAssignmentRowBusyError() + } + throw error + } + } + private async lockAssignmentActivities( database: RelayDatabase, identity: AssignmentIdentity, @@ -8233,6 +8449,19 @@ function activityCounts(rows: SqlRow[]): Record return counts } +function leaseRequestUnits(rows: SqlRow[]): number { + return rows.reduce((total, row) => total + integer(row, 'request_units'), 0) +} + +function leaseUnitsByCell(rows: SqlRow[]): Map { + const units = new Map() + for (const row of rows) { + const cellId = text(row, 'cell_id') + units.set(cellId, (units.get(cellId) ?? 0) + integer(row, 'request_units')) + } + return units +} + function activityUnitsForCell(rows: SqlRow[], cellId: string): number { return rows.reduce( (total, row) => diff --git a/cloud/apps/relay/src/drain-release-cell-row-contention-postgres.test.ts b/cloud/apps/relay/src/drain-release-cell-row-contention-postgres.test.ts new file mode 100644 index 000000000000..23f7877ac7f7 --- /dev/null +++ b/cloud/apps/relay/src/drain-release-cell-row-contention-postgres.test.ts @@ -0,0 +1,773 @@ +import { createHash } from 'node:crypto' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { RelayAssignmentRowBusyError, RelayAssignmentStore } from './assignment-store.js' +import { + encodeMembership, + type CellAdmissionMembership, + type CellAdmissionState +} from './cell-admission-selector.js' +import type { RelayCellConfig } from './config.js' +import { + consumeRelayCellInventoryHold, + consumeRelayDatabasePoolPressure, + openRelayDatabase, + type RelayDatabase +} from './database.js' +import { RelayPublicAssignmentAdmission } from './public-assignment-admission.js' +import { + openDelayedPostgresDatabase, + type StatementDelay +} from './test-fixtures/delayed-postgres-database.js' + +// Reproduces the Asia drain: a draining cell's releases each end with a one-row +// UPDATE of the source relay_cells row, held for a round trip to COMMIT, while +// the directors re-place its reconnecting hosts. Placement used to lock every +// relay_cells row and starved (about 9/s at 6 releases/s, 5/s at 18/s, every +// director backend lock-waiting); it now locks only the targets. Prints one +// line per run and asserts the after picture. + +const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL +const describePostgres = databaseUrl ? describe : describe.skip + +// asia-east2 cell to the us-central1 database, measured in production. +const ASIA_ROUND_TRIP_MS = 171 +const LOCAL_ROUND_TRIP_MS = 1 +const WINDOW_MS = 10_000 +// Reconnecting hosts dialling the directors during the window. Above the +// ~6-15/s the production tail sustained, so the director side is never idle. +const DIAL_RATE_PER_SECOND = 20 +// Production shapes: five director instances (production.tfvars), each with +// the default pool and sticky lane (variables.tf, config.ts), and an Asia +// cell's pool (validate-relay-asia-topology-plan.mjs). +const DIRECTOR_INSTANCES = 5 +const DIRECTOR_POOL_MAX = 3 +const STICKY_LANE = { maxConcurrent: 1, maxQueued: 64, waitMs: 2_000, minIntervalMs: 2_000 } +const CELL_POOL_MAX = 16 +const DIRECTOR_APPLICATION = 'drain-release-contention-director' + +const USER_PREFIX = 'drain-release-contention' +const NOW = 1_000_000 +const CAPPED = { + capacityRequests: 6_000, + connectionHardCap: 3_000, + connectionUnobservedBound: 60 +} as const +const SOURCE: RelayCellConfig = { + id: 'drain-release-contention-a-source', + url: 'https://drain-release-contention-source.example.test', + region: 'asia-east2', + ...CAPPED +} +const TARGETS: RelayCellConfig[] = ['b', 'c', 'd'].map((suffix) => ({ + id: `drain-release-contention-${suffix}-target`, + url: `https://drain-release-contention-${suffix}-target.example.test`, + region: 'asia-east2', + ...CAPPED +})) +// Next to the database. Taken only once every Asia neighbour is at its cap. +const US_CELL: RelayCellConfig = { + id: 'drain-release-contention-e-us', + url: 'https://drain-release-contention-us.example.test', + region: 'us-central1', + ...CAPPED +} +const CELLS = [SOURCE, ...TARGETS, US_CELL] +const RELEASED_ACTIVITY = `control:${SOURCE.id}:1` + +// The desktop client books each host's next /v1/assign 5-5.5s after its last +// one, whatever Retry-After says (src/main/runtime/relay/relay-assign-rate-gate.ts). +const HOST_ASSIGN_MIN_INTERVAL_MS = 5_000 +const HOST_ASSIGN_INTERVAL_JITTER_MS = 500 +// A host still unplaced this long after its release counts as never placed. +const REDIAL_GIVE_UP_MS = 30_000 + +type Identity = { userId: string; relayHostId: string } +type Tally = Record +type Instance = { database: RelayDatabase; store: RelayAssignmentStore } + +type RunReport = { + roundTripMs: number + releaseRate: number + neighboursCapped: boolean + releases: { attempted: number; ok: number; failed: Tally; p50Ms: number; p95Ms: number } + dials: { + attempted: number + placed: number + placedInWindow: number + placedCrossRegion: number + admissionRejected: Tally + failed: Tally + } + activations: { ok: number; failed: Tally } + placementsPerSecond: number + director: { + lockUnavailable: number + lockTimeouts: number + holds: number + holdMsMax: number + // Mean director backends blocked on a lock per 20ms sample, of those active. + lockWaitingMean: number + activeMean: number + } + sourcePoolWaitersMax: number +} + +type DepartingReport = { + dialDelayMs: number + neighboursCapped: boolean + hosts: number + placed: number + firstAttempt: { placed: number; rejected: Tally; failed: Tally } + // Row-busy refusals on any attempt, and errors no client would retry. + busyRefusals: number + unexpected: Tally + redials: number + // Release start to the grant that re-placed the host, redials included. + timeToPlacedMs: { p50: number; p95: number; max: number } + placementsPerSecond: number + releases: { ok: number; failed: Tally } + lockWaitingMean: number + activeMean: number +} + +function hostIdentity(index: number): Identity { + return { + userId: `${USER_PREFIX}-${index}`, + // Relay host ids are fixed-width opaque ids. + relayHostId: `drainrel${String(index).padStart(8, '0')}` + } +} + +function failureCode(error: unknown): string { + if (error instanceof Error) { + const code = 'code' in error ? error.code : undefined + return typeof code === 'string' ? code : error.message + } + return String(error) +} + +function count(tally: Tally, key: string): void { + tally[key] = (tally[key] ?? 0) + 1 +} + +function total(tally: Tally): number { + return Object.values(tally).reduce((sum, value) => sum + value, 0) +} + +function percentile(values: number[], fraction: number): number { + if (values.length === 0) return 0 + const sorted = [...values].sort((left, right) => left - right) + return Math.round(sorted[Math.min(sorted.length - 1, Math.floor(fraction * sorted.length))]!) +} + +function mean(sum: number, samples: number): number { + return samples === 0 ? 0 : Number((sum / samples).toFixed(2)) +} + +// Fires `operation` at a fixed rate without awaiting it, like independent +// socket closes or dials would. +async function paced( + ratePerSecond: number, + total: number, + operation: (index: number) => Promise +): Promise[]> { + const startedAt = performance.now() + const started: Promise[] = [] + for (let index = 0; index < total; index += 1) { + const dueMs = (index * 1_000) / ratePerSecond - (performance.now() - startedAt) + if (dueMs > 0) await new Promise((resolve) => setTimeout(resolve, dueMs)) + started.push(operation(index)) + } + return started +} + +describePostgres('PostgreSQL drain releases against director placement', () => { + const storeOptions = { requireLiveCells: true, heartbeatTtlMs: 45_000 } + // One switch for every Asia pool: the source cell and each target cell. + const delay: StatementDelay = { enabled: false, delayMs: LOCAL_ROUND_TRIP_MS } + const directors: Instance[] = [] + const servingCells = new Map() + let observer: RelayDatabase + const reports: RunReport[] = [] + const departingReports: DepartingReport[] = [] + + function admin(): Instance { + return directors[0]! + } + + function servingCell(cellId: string): Instance { + const instance = servingCells.get(cellId) + if (!instance) throw new Error(`no cell pool for ${cellId}`) + return instance + } + + async function applySelector( + states: Record, + rollIsolatedCells?: string[] + ): Promise { + const current = await admin().store.inspectCellAdmissionSelector() + // Built from relay_cells: the apply requires exact coverage of a shared fleet. + const fleet = await admin().database.query( + `SELECT cell_id FROM relay_cells ORDER BY cell_id ASC` + ) + const membership: CellAdmissionMembership = { + existingOnly: [], + migrationOnly: [], + general: [] + } + for (const row of fleet) { + const cellId = String(row['cell_id']) + const state = + states[cellId] ?? + (current.selector.membership.existingOnly.includes(cellId) + ? 'existing-only' + : current.selector.membership.migrationOnly.includes(cellId) + ? 'migration-only' + : 'general') + if (state === 'existing-only') membership.existingOnly.push(cellId) + else if (state === 'migration-only') membership.migrationOnly.push(cellId) + else membership.general.push(cellId) + } + await admin().store.applyCellAdmissionSelector({ + attemptId: `drain-release-${current.selector.generation}`, + expectedGeneration: current.selector.generation, + ...(current.selector.generation === 0 + ? { + expectedMembershipSha256: createHash('sha256') + .update(encodeMembership(current.selector.membership)) + .digest('hex') + } + : {}), + membership, + ...(rollIsolatedCells ? { rollIsolatedCells } : {}) + }) + } + + // Other Postgres files write admission through the generation-0 helpers, so + // the selector goes back to generation 0 exactly as found. + async function resetSelectorBoundary(): Promise { + await admin().database.query( + `UPDATE relay_admission_selectors SET generation = 0, attempt_id = NULL + WHERE selector_id = 'general'` + ) + await admin().database.query( + `DELETE FROM relay_admission_selector_intents WHERE attempt_id LIKE 'drain-release-%'` + ) + await admin().store.reconcileCells([], false) + } + + async function deleteHostRows(): Promise { + for (const table of [ + 'relay_control_capabilities', + 'relay_control_connection_reservations', + 'relay_assignment_activity_leases', + 'relay_assignment_migrations', + 'relay_assignment_region_preferences', + 'relay_assignments' + ]) { + await admin().database.query(`DELETE FROM ${table} WHERE user_id LIKE '${USER_PREFIX}-%'`) + } + } + + // `atCap` reports those cells at their connection cap: no headroom left. + async function heartbeatAll(atCap: RelayCellConfig[] = []): Promise { + for (const [index, config] of CELLS.entries()) { + await admin().store.recordCellHeartbeat({ + cellId: config.id, + cellUrl: config.url, + cellIncarnation: `2222222${index}-2222-4222-8222-222222222222`, + startedAt: NOW - 1_000, + ready: true, + observedRequests: 0, + region: config.region, + // The cell checks enforced = total + in-flight + reserved. + totalConnections: atCap.includes(config) ? CAPPED.connectionHardCap : 0, + inFlightConnections: 0, + reservedConnectionUnits: 0, + enforcedConnectionUnits: atCap.includes(config) ? CAPPED.connectionHardCap : 0, + connectionHardCap: CAPPED.connectionHardCap, + connectionUnobservedBound: CAPPED.connectionUnobservedBound + }) + } + } + + // Every host holds a live control lease on the source, then the source is + // isolated for a roll: the state an Asia drain starts from. + async function seed(hosts: Identity[]): Promise { + await deleteHostRows() + await admin().database.query( + `UPDATE relay_cells SET reserved_requests = 0 WHERE cell_id LIKE '${USER_PREFIX}-%'` + ) + // Restoring the source to general also clears the previous run's roll stamp. + await applySelector({ + [SOURCE.id]: 'general', + ...Object.fromEntries(TARGETS.map((target) => [target.id, 'migration-only' as const])) + }) + for (const identity of hosts) { + const grant = await admin().store.assign(identity, 'asia-east2', 'asia-east2') + expect(grant.cellId).toBe(SOURCE.id) + await admin().store.activateControl(identity, { + cellId: SOURCE.id, + assignmentEpoch: grant.assignmentEpoch, + generation: 1 + }) + } + await applySelector( + Object.fromEntries(TARGETS.map((target) => [target.id, 'general' as const])) + ) + await applySelector({ [SOURCE.id]: 'migration-only' }, [SOURCE.id]) + } + + // Samples director backends so the report shows where director time goes: a + // failed or slow placement leaves no hold sample behind. + function sampleDirectorWaits(): { stop: () => Promise<{ waiting: number; active: number }> } { + let running = true + let samples = 0 + let waiting = 0 + let active = 0 + const loop = (async () => { + while (running) { + const rows = await observer.query( + `SELECT wait_event_type FROM pg_stat_activity + WHERE application_name = ? AND state = 'active'`, + [DIRECTOR_APPLICATION] + ) + samples += 1 + active += rows.length + waiting += rows.filter((row) => row['wait_event_type'] === 'Lock').length + await new Promise((resolve) => setTimeout(resolve, 20)) + } + })() + return { + stop: async () => { + running = false + await loop + return { waiting: mean(waiting, samples), active: mean(active, samples) } + } + } + } + + async function run( + roundTripMs: number, + releaseRate: number, + neighboursCapped = false + ): Promise { + const releaseCount = Math.round((releaseRate * WINDOW_MS) / 1_000) + const dialCount = Math.round((DIAL_RATE_PER_SECOND * WINDOW_MS) / 1_000) + const releasing = Array.from({ length: releaseCount }, (_, index) => hostIdentity(index)) + const dialling = Array.from({ length: dialCount }, (_, index) => + hostIdentity(releaseCount + index) + ) + await seed([...releasing, ...dialling]) + if (neighboursCapped) await heartbeatAll(TARGETS) + + const releaseLatencies: number[] = [] + const releaseFailed: Tally = {} + const releases = { attempted: 0, ok: 0, failed: releaseFailed } + const admissionRejected: Tally = {} + const dialFailed: Tally = {} + const dials = { + attempted: 0, + placed: 0, + placedInWindow: 0, + placedCrossRegion: 0, + admissionRejected, + failed: dialFailed + } + const activationFailed: Tally = {} + const activations = { ok: 0, failed: activationFailed } + const activationWork: Promise[] = [] + const stickyLanes = directors.map(() => new RelayPublicAssignmentAdmission(STICKY_LANE)) + for (const instance of directors) consumeRelayCellInventoryHold(instance.database) + consumeRelayDatabasePoolPressure(servingCell(SOURCE.id).database) + delay.delayMs = roundTripMs + delay.enabled = true + const sampler = sampleDirectorWaits() + const startedAt = performance.now() + + const releaseWork = paced(releaseRate, releaseCount, async (index) => { + releases.attempted += 1 + const began = performance.now() + try { + const released = await servingCell(SOURCE.id).store.releaseActivity( + releasing[index]!, + RELEASED_ACTIVITY + ) + if (released) releases.ok += 1 + else count(releases.failed, 'lease_missing') + } catch (error) { + count(releases.failed, failureCode(error)) + } + releaseLatencies.push(performance.now() - began) + }) + // The reconnect path, round-robin over directors: sticky-lane admission, + // the durable-pin check, then the assign the route calls, which re-places + // off the isolated cell. The host then attaches to its new Asia cell. + const dialWork = paced(DIAL_RATE_PER_SECOND, dialCount, async (index) => { + const identity = dialling[index]! + const director = directors[index % directors.length]! + dials.attempted += 1 + let rejection = 'unknown' + const lease = await stickyLanes[index % directors.length]!.acquire( + identity.relayHostId, + (reason) => { + rejection = reason + } + ) + if (!lease) { + count(dials.admissionRejected, rejection) + return + } + try { + if (!(await director.store.resolve(identity))) { + count(dials.failed, 'unverified') + return + } + const grant = await director.store.assign(identity, 'asia-east2', 'asia-east2') + if (grant.cellId === SOURCE.id) { + count(dials.failed, 'kept_on_source') + return + } + dials.placed += 1 + if (grant.region !== SOURCE.region) dials.placedCrossRegion += 1 + if (performance.now() - startedAt <= WINDOW_MS) dials.placedInWindow += 1 + activationWork.push( + servingCell(grant.cellId) + .store.activateControl(identity, { + cellId: grant.cellId, + assignmentEpoch: grant.assignmentEpoch, + generation: 1 + }) + .then( + () => { + activations.ok += 1 + }, + (error: unknown) => count(activations.failed, failureCode(error)) + ) + ) + } catch (error) { + count(dials.failed, failureCode(error)) + } finally { + lease.release() + } + }) + await Promise.allSettled([...(await releaseWork), ...(await dialWork)]) + await Promise.allSettled(activationWork) + const waits = await sampler.stop() + delay.enabled = false + if (neighboursCapped) await heartbeatAll() + + const holds = directors.map((instance) => consumeRelayCellInventoryHold(instance.database)) + const report: RunReport = { + roundTripMs, + releaseRate, + neighboursCapped, + releases: { + ...releases, + p50Ms: percentile(releaseLatencies, 0.5), + p95Ms: percentile(releaseLatencies, 0.95) + }, + dials, + activations, + placementsPerSecond: Number(((dials.placedInWindow * 1_000) / WINDOW_MS).toFixed(1)), + director: { + lockUnavailable: holds.reduce((sum, hold) => sum + hold.cellInventoryLockUnavailable, 0), + lockTimeouts: holds.reduce((sum, hold) => sum + hold.cellInventoryLockTimeouts, 0), + holds: holds.reduce((sum, hold) => sum + hold.cellInventoryHolds, 0), + holdMsMax: Math.round(Math.max(...holds.map((hold) => hold.cellInventoryHoldMsMax))), + lockWaitingMean: waits.waiting, + activeMean: waits.active + }, + sourcePoolWaitersMax: consumeRelayDatabasePoolPressure(servingCell(SOURCE.id).database) + .databasePoolWaitersMax + } + reports.push(report) + console.info(JSON.stringify({ event: 'drain_release_cell_row_contention', ...report })) + return report + } + + // Production's shape: the host whose socket the draining cell closes is the + // one that redials. Its release locks its own assignment row first and holds + // it while queued on the source row, so its own placement can meet it. + async function runDepartingRedial( + dialDelayMs: number, + neighboursCapped: boolean + ): Promise { + const releaseRate = 18 + const hosts = Array.from({ length: (releaseRate * WINDOW_MS) / 1_000 }, (_, index) => + hostIdentity(index) + ) + await seed(hosts) + if (neighboursCapped) await heartbeatAll(TARGETS) + const stickyLanes = directors.map(() => new RelayPublicAssignmentAdmission(STICKY_LANE)) + const firstRejected: Tally = {} + const firstFailed: Tally = {} + const releaseFailed: Tally = {} + const firstAttempt = { placed: 0, rejected: firstRejected, failed: firstFailed } + const releases = { ok: 0, failed: releaseFailed } + const timeToPlaced: number[] = [] + let redials = 0 + let busyRefusals = 0 + const unexpected: Tally = {} + let placedInWindow = 0 + const activationWork: Promise[] = [] + delay.delayMs = ASIA_ROUND_TRIP_MS + delay.enabled = true + const sampler = sampleDirectorWaits() + const startedAt = performance.now() + + // One dial through the sticky lane; 'placed', or the reason it was not. + async function dial(identity: Identity, director: number): Promise { + let rejection = 'unknown' + const lease = await stickyLanes[director]!.acquire(identity.relayHostId, (reason) => { + rejection = reason + }) + if (!lease) return `rejected:${rejection}` + try { + if (!(await directors[director]!.store.resolve(identity))) return 'failed:unverified' + const grant = await directors[director]!.store.assign(identity, 'asia-east2', 'asia-east2') + if (grant.cellId === SOURCE.id) return 'failed:kept_on_source' + activationWork.push( + servingCell(grant.cellId) + .store.activateControl(identity, { + cellId: grant.cellId, + assignmentEpoch: grant.assignmentEpoch, + generation: 1 + }) + .catch(() => undefined) + ) + return 'placed' + } catch (error) { + if (error instanceof RelayAssignmentRowBusyError) return `rejected:${error.message}` + return `failed:${failureCode(error)}` + } finally { + lease.release() + } + } + + const work = paced(releaseRate, hosts.length, async (index) => { + const identity = hosts[index]! + const releasedAt = performance.now() + const release = servingCell(SOURCE.id) + .store.releaseActivity(identity, RELEASED_ACTIVITY) + .then( + (released) => { + if (released) releases.ok += 1 + else count(releases.failed, 'lease_missing') + }, + (error: unknown) => count(releases.failed, failureCode(error)) + ) + await new Promise((resolve) => setTimeout(resolve, dialDelayMs)) + const director = index % directors.length + for (let attempt = 0; ; attempt += 1) { + const sentAt = performance.now() + const outcome = await dial(identity, director) + if (outcome === `rejected:${new RelayAssignmentRowBusyError().message}`) busyRefusals += 1 + if (outcome.startsWith('failed:')) count(unexpected, outcome.slice(7)) + if (attempt === 0) { + if (outcome === 'placed') firstAttempt.placed += 1 + else if (outcome.startsWith('rejected:')) count(firstRejected, outcome.slice(9)) + else count(firstFailed, outcome.slice(7)) + } + if (outcome === 'placed') { + const placedAt = performance.now() + timeToPlaced.push(placedAt - releasedAt) + if (placedAt - startedAt <= WINDOW_MS + dialDelayMs) placedInWindow += 1 + break + } + if (!outcome.startsWith('rejected:')) break + const nextAt = + sentAt + + HOST_ASSIGN_MIN_INTERVAL_MS + + Math.random() * HOST_ASSIGN_INTERVAL_JITTER_MS + if (nextAt - releasedAt > REDIAL_GIVE_UP_MS) break + redials += 1 + await new Promise((resolve) => setTimeout(resolve, nextAt - performance.now())) + } + await release + }) + await Promise.allSettled(await work) + await Promise.allSettled(activationWork) + const waits = await sampler.stop() + delay.enabled = false + if (neighboursCapped) await heartbeatAll() + const report: DepartingReport = { + dialDelayMs, + neighboursCapped, + hosts: hosts.length, + placed: timeToPlaced.length, + firstAttempt, + busyRefusals, + unexpected, + redials, + timeToPlacedMs: { + p50: percentile(timeToPlaced, 0.5), + p95: percentile(timeToPlaced, 0.95), + max: percentile(timeToPlaced, 1) + }, + placementsPerSecond: Number(((placedInWindow * 1_000) / WINDOW_MS).toFixed(1)), + releases, + lockWaitingMean: waits.waiting, + activeMean: waits.active + } + departingReports.push(report) + console.info(JSON.stringify({ event: 'drain_departing_host_redial', ...report })) + return report + } + + beforeAll(async () => { + for (let index = 0; index < DIRECTOR_INSTANCES; index += 1) { + const database = await openRelayDatabase({ + databaseUrl, + dataDir: '', + poolMax: DIRECTOR_POOL_MAX, + applicationName: DIRECTOR_APPLICATION + }) + directors.push({ + database, + store: new RelayAssignmentStore(database, () => NOW, storeOptions) + }) + } + for (const config of CELLS) { + const database = openDelayedPostgresDatabase( + databaseUrl!, + config === US_CELL ? { enabled: false, delayMs: 0 } : delay, + CELL_POOL_MAX + ) + servingCells.set(config.id, { + database, + store: new RelayAssignmentStore(database, () => NOW, storeOptions) + }) + } + observer = await openRelayDatabase({ databaseUrl, dataDir: '', poolMax: 1 }) + await deleteHostRows() + await admin().store.reconcileCells(CELLS, false) + await resetSelectorBoundary() + await heartbeatAll() + }) + + afterAll(async () => { + delay.enabled = false + if (reports.length > 0) console.table(reports.map(flattenReport)) + if (departingReports.length > 0) console.table(departingReports.map(flattenDeparting)) + if (directors.length > 0) { + await deleteHostRows() + // Cells before the selector rebuild, or its membership names missing rows. + for (const config of CELLS) { + for (const table of [ + 'relay_cell_connection_snapshots', + 'relay_cell_connection_runtime', + 'relay_cell_connection_limits', + 'relay_cell_runtime', + 'relay_cell_admission', + 'relay_cell_regions', + 'relay_cells' + ]) { + await admin().database.query(`DELETE FROM ${table} WHERE cell_id = ?`, [config.id]) + } + } + await resetSelectorBoundary() + } + for (const instance of [...directors, ...servingCells.values()]) await instance.database.close() + await observer?.close() + }) + + it('keeps placing during a paced drain when the cells are next to the database', async () => { + for (const rate of [6, 18]) { + const report = await run(LOCAL_ROUND_TRIP_MS, rate) + expect(report.dials.failed).toEqual({}) + // Failures, rejections and lock waits, not placements/s: pacing on a slow runner + // moves the rate without any lock being involved. + expect(total(report.dials.admissionRejected)).toBeLessThan(0.1 * report.dials.attempted) + expect(report.director.lockWaitingMean).toBeLessThan(0.5) + } + }, 120_000) + + it('keeps placing while an Asia drain holds the source row', async () => { + // Before the fix: 90 and 140 of 200 dials rejected by the sticky lane and + // about 3.5 of 3.5 active director backends lock-waiting. Pass bars are on + // those, not on placements/s, which a slow runner's pacing alone can move. + for (const [rate, neighboursCapped] of [ + [6, false], + [18, false], + [18, true] + ] as const) { + const report = await run(ASIA_ROUND_TRIP_MS, rate, neighboursCapped) + expect(report.dials.failed).toEqual({}) + expect(total(report.dials.admissionRejected)).toBeLessThan(0.1 * report.dials.attempted) + expect(report.director.lockWaitingMean).toBeLessThan(0.5) + expect(report.dials.placedCrossRegion).toBe(neighboursCapped ? report.dials.placed : 0) + if (rate === 18) { + // Still cell-side: releases on one row serialise at ~1/RTT (~5.8/s) + // and the excess sheds to lease expiry. The director no longer waits. + expect(report.releases.ok).toBeLessThan(0.6 * report.releases.attempted) + } + } + }, 240_000) + + it('re-places departing hosts that redial after their own release', async () => { + for (const neighboursCapped of [false, true]) { + for (const dialDelayMs of [150, 400, 1_000]) { + const report = await runDepartingRedial(dialDelayMs, neighboursCapped) + expect(report.unexpected).toEqual({}) + expect(report.placed).toBe(report.hosts) + // A busy own row is refused at once by design; what must not happen is + // the sticky slot backing up behind it. Before: 72-143 wait-timeouts at + // 400ms and 1s, 2.4-3.6 director backends lock-waiting. + const { relay_assignment_row_busy: _refused, ...slotRejections } = + report.firstAttempt.rejected + expect(total(slotRejections)).toBeLessThan(0.1 * report.hosts) + expect(report.lockWaitingMean).toBeLessThan(0.5) + // Measured 16-20 refusals of 180 and a p95 of 5.7-6.3s; before, p95 was 11-16s. + expect(report.busyRefusals).toBeLessThanOrEqual(0.2 * report.hosts) + expect(report.timeToPlacedMs.p95).toBeLessThanOrEqual(8_000) + } + } + }, 600_000) +}) + +function flattenReport(report: RunReport): Record { + const failed = (tally: Tally): string => + Object.entries(tally) + .map(([key, value]) => `${key}:${value}`) + .join(' ') || '-' + return { + rttMs: report.roundTripMs, + capped: report.neighboursCapped ? 'yes' : 'no', + crossRegion: report.dials.placedCrossRegion, + releasesPerSec: report.releaseRate, + releasesOk: report.releases.ok, + releasesFailed: failed(report.releases.failed), + releaseP95Ms: report.releases.p95Ms, + dialsPlaced: report.dials.placed, + placementsPerSec: report.placementsPerSecond, + dialsFailed: failed(report.dials.failed), + stickyRejected: failed(report.dials.admissionRejected), + lockTimeouts: report.director.lockTimeouts, + lockUnavailable: report.director.lockUnavailable, + lockWaiting: `${report.director.lockWaitingMean}/${report.director.activeMean}`, + activationsFailed: failed(report.activations.failed) + } +} + +function flattenDeparting(report: DepartingReport): Record { + const failed = (tally: Tally): string => + Object.entries(tally) + .map(([key, value]) => `${key}:${value}`) + .join(' ') || '-' + return { + delayMs: report.dialDelayMs, + capped: report.neighboursCapped ? 'yes' : 'no', + placed: `${report.placed}/${report.hosts}`, + firstPlaced: report.firstAttempt.placed, + firstRejected: failed(report.firstAttempt.rejected), + busy: report.busyRefusals, + unexpected: failed(report.unexpected), + redials: report.redials, + placedP50Ms: report.timeToPlacedMs.p50, + placedP95Ms: report.timeToPlacedMs.p95, + placedMaxMs: report.timeToPlacedMs.max, + placementsPerSec: report.placementsPerSecond, + lockWaiting: `${report.lockWaitingMean}/${report.activeMean}` + } +} diff --git a/cloud/apps/relay/src/public-assignment-reconnect-lane.test.ts b/cloud/apps/relay/src/public-assignment-reconnect-lane.test.ts index 145d2c737cb6..7244fc9577da 100644 --- a/cloud/apps/relay/src/public-assignment-reconnect-lane.test.ts +++ b/cloud/apps/relay/src/public-assignment-reconnect-lane.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import type { RelayAssignment } from './assignment-store.js' +import { RelayAssignmentRowBusyError, type RelayAssignment } from './assignment-store.js' import type { RelayConfig } from './config.js' const fakes = vi.hoisted(() => ({ @@ -126,6 +126,70 @@ describe('public assignment reconnect lane', () => { expect(assign).not.toHaveBeenCalled() }) + it('refuses a dial whose assignment row is busy at once, and frees the fast lane', async () => { + const busy = 'bbbbbbbbbbbbbbbb' + const next = 'nnnnnnnnnnnnnnnn' + const assign = vi.fn(async ({ relayHostId }: { relayHostId: string }) => { + if (relayHostId === busy) throw new RelayAssignmentRowBusyError() + return assignment('cell-n', next) + }) + const resolve = vi.fn(async ({ relayHostId }: { relayHostId: string }) => + assignment('cell-r', relayHostId) + ) + const app = createRelayApp(config(), { + store: {} as never, + assignments: { assign, resolve } as never, + drain: vi.fn(), + ready: vi.fn(async () => true) + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const refused = await app.request('/v1/assign', assignmentRequest(busy, { reconnect: true })) + + expect(refused.status).toBe(503) + expect(refused.headers.get('retry-after')).toBe('1') + expect(await refused.json()).toEqual({ error: 'assignment_row_busy' }) + expect(warn.mock.calls.flat().join('\n')).toContain('reason=relay_assignment_row_busy') + // The single fast-lane slot went back with the refusal. + expect( + (await app.request('/v1/assign', assignmentRequest(next, { reconnect: true }))).status + ).toBe(200) + warn.mockRestore() + }) + + it('asks a busy resume to retry in a second', async () => { + const host = 'rrrrrrrrrrrrrrrr' + const app = createRelayApp(config(), { + store: { + resolveResume: vi.fn(async () => ({ userId: 'user-1', relayDeviceId: 'device-1' })) + } as never, + assignments: { + assign: vi.fn(async () => { + throw new RelayAssignmentRowBusyError() + }), + resolve: vi.fn(async () => null) + } as never, + drain: vi.fn(), + ready: vi.fn(async () => true) + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const response = await app.request('/v1/resolve', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + v: 1, + relayHostId: host, + resumeToken: Buffer.alloc(32, 1).toString('base64url') + }) + }) + + expect(response.status).toBe(503) + expect(response.headers.get('retry-after')).toBe('1') + expect(await response.json()).toEqual({ error: 'relay_assignment_row_busy' }) + warn.mockRestore() + }) + it('never probes for unhinted requests', async () => { const host = 'uuuuuuuuuuuuuuuu' const assign = vi.fn(async () => assignment('cell-u', host)) diff --git a/cloud/apps/relay/src/regional-rehome-target-row-lock-postgres.test.ts b/cloud/apps/relay/src/regional-rehome-target-row-lock-postgres.test.ts index 2263820407ad..314b758b17f3 100644 --- a/cloud/apps/relay/src/regional-rehome-target-row-lock-postgres.test.ts +++ b/cloud/apps/relay/src/regional-rehome-target-row-lock-postgres.test.ts @@ -1,13 +1,16 @@ -import pg from 'pg' import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' import { RelayAssignmentStore } from './assignment-store.js' import { consumeRelayCellInventoryHold, openRelayDatabase, - PostgresDatabase, + type PostgresDatabase, type RelayDatabase } from './database.js' import { readRegionCorrectionOutcomes } from './region-correction-outcomes.js' +import { + openDelayedPostgresDatabase, + type StatementDelay +} from './test-fixtures/delayed-postgres-database.js' const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL const describePostgres = databaseUrl ? describe : describe.skip @@ -31,24 +34,23 @@ const TARGET_LOCKED = ['target:relay_cells', 'target:relay_cell_admission'] type Trip = { sql: string; lockable: Record } -type DelayControl = { - enabled: boolean - // Runs before each delayed statement leaves the client, so it sees the locks - // the transaction holds between round trips. - beforeTrip: (sql: string) => Promise -} +type DelayControl = StatementDelay & { beforeTrip: (sql: string) => Promise } describePostgres('PostgreSQL regional rehome target-row lock', () => { let primary: RelayDatabase let observer: RelayDatabase let delayed: PostgresDatabase - const control: DelayControl = { enabled: false, beforeTrip: async () => undefined } + const control: DelayControl = { + enabled: false, + delayMs: STATEMENT_DELAY_MS, + beforeTrip: async () => undefined + } let sequence = 0 beforeAll(async () => { primary = await openRelayDatabase({ databaseUrl, dataDir: '' }) observer = await openRelayDatabase({ databaseUrl, dataDir: '' }) - delayed = openDelayedDatabase(databaseUrl!, control) + delayed = openDelayedPostgresDatabase(databaseUrl!, control) }) beforeEach(async () => { @@ -342,32 +344,6 @@ describePostgres('PostgreSQL regional rehome target-row lock', () => { } }) -// Same session settings as the serving pool, with a fixed delay in front of -// every statement, BEGIN and COMMIT included. -function openDelayedDatabase(url: string, control: DelayControl): PostgresDatabase { - const pool = new pg.Pool({ - connectionString: url, - max: 4, - statement_timeout: 5_000, - lock_timeout: 1_000, - idle_in_transaction_session_timeout: 5_000 - }) - pool.on('error', () => undefined) - pool.on('connect', (client) => { - const query = client.query - Object.assign(client, { - query: async (...args: unknown[]) => { - if (control.enabled) { - await control.beforeTrip(typeof args[0] === 'string' ? args[0] : '') - await new Promise((resolve) => setTimeout(resolve, STATEMENT_DELAY_MS)) - } - return await Reflect.apply(query, client, args) - } - }) - }) - return new PostgresDatabase(pool) -} - const storeOptions = { regionalRehomeCohortPercent: 100, requireLiveCells: true, diff --git a/cloud/apps/relay/src/test-fixtures/delayed-postgres-database.ts b/cloud/apps/relay/src/test-fixtures/delayed-postgres-database.ts new file mode 100644 index 000000000000..eef94c529576 --- /dev/null +++ b/cloud/apps/relay/src/test-fixtures/delayed-postgres-database.ts @@ -0,0 +1,48 @@ +import pg from 'pg' +import { + absorbPostgresIdleClientErrors, + POSTGRES_LOCK_TIMEOUT_MS, + POSTGRES_STATEMENT_TIMEOUT_MS, + PostgresDatabase +} from '../database.js' + +export type StatementDelay = { + enabled: boolean + delayMs: number + // Runs before each delayed statement leaves the client, so it sees the locks + // the transaction holds between round trips. + beforeTrip?: (sql: string) => Promise +} + +// The serving pool's session settings with a fixed delay in front of every +// statement, BEGIN and COMMIT included: one delay per cross-region round trip. +export function openDelayedPostgresDatabase( + url: string, + delay: StatementDelay, + poolMax = 4 +): PostgresDatabase { + const pool = new pg.Pool({ + connectionString: url, + max: poolMax, + // Same bound as openRelayDatabase, so a saturated pool fails acquires the + // way a serving cell does instead of queueing forever. + connectionTimeoutMillis: 2_000, + statement_timeout: POSTGRES_STATEMENT_TIMEOUT_MS, + lock_timeout: POSTGRES_LOCK_TIMEOUT_MS, + idle_in_transaction_session_timeout: 5_000 + }) + absorbPostgresIdleClientErrors(pool) + pool.on('connect', (client) => { + const query = client.query + Object.assign(client, { + query: async (...args: unknown[]) => { + if (delay.enabled) { + await delay.beforeTrip?.(typeof args[0] === 'string' ? args[0] : '') + await new Promise((resolve) => setTimeout(resolve, delay.delayMs)) + } + return await Reflect.apply(query, client, args) + } + }) + }) + return new PostgresDatabase(pool) +} diff --git a/cloud/dev/scripts/check-relay-same-cap-headroom.mjs b/cloud/dev/scripts/check-relay-same-cap-headroom.mjs new file mode 100644 index 000000000000..95fad7b8543b --- /dev/null +++ b/cloud/dev/scripts/check-relay-same-cap-headroom.mjs @@ -0,0 +1,154 @@ +import { pathToFileURL } from 'node:url' +import { fetchAdminOnceMore } from './relay-admin-transient-retry.mjs' + +// Drained hosts that find no free slot keep redialling and pin the drained cell (c28, +// 2026-10-01). Hosts are counted as controls, but each moved host also brings its splices; +// the 20% margin is for those. +export const MAX_HEADROOM_FRACTION = 0.8 + +function count(value, name) { + if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${name} is invalid`) + return value +} + +export function parseHeadroomArguments(argv) { + const values = {} + for (let index = 0; index < argv.length; index += 2) { + const key = argv[index] + const value = argv[index + 1] + if (!key?.startsWith('--') || value === undefined) throw new Error('invalid arguments') + values[key.slice(2)] = value + } + for (const key of ['director-origin', 'cell-origin', 'cell-id', 'general-cells']) { + if (!values[key]) throw new Error(`missing --${key}`) + } + for (const key of ['director-origin', 'cell-origin']) { + const url = new URL(values[key]) + if (url.protocol !== 'https:' || url.origin !== values[key]) { + throw new Error('origins must be canonical HTTPS origins') + } + } + const generalCells = values['general-cells'] === 'none' + ? [] + : values['general-cells'].split(',') + if ( + new Set(generalCells).size !== generalCells.length || + generalCells.some((cellId) => !/^[a-z0-9-]+$/.test(cellId)) + ) { + throw new Error('--general-cells is invalid') + } + return { + directorOrigin: values['director-origin'], + cellOrigin: values['cell-origin'], + cellId: values['cell-id'], + generalCells + } +} + +async function adminJson(fetchImpl, url, token, body, wait) { + const response = await fetchAdminOnceMore( + fetchImpl, + url, + { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify(body) + }, + { wait } + ) + const json = await response.json().catch(() => ({})) + if (!response.ok) throw new Error(`${new URL(url).pathname} returned ${response.status}`) + return json +} + +// A cell whose capacity view is stale, absent, or not general offers no slot we can count. +// Placement admits while enforced units plus outstanding reservations stay under the pause. +function freeSlots(status) { + const capacity = status.connectionCapacity + if ( + status.enabled !== true || + status.admissionState !== 'general' || + capacity === null || + capacity === undefined || + capacity.heartbeatFresh !== true + ) { + return 0 + } + const pause = count(capacity.normalAdmissionPause, 'normal admission pause') + const used = Math.max( + count(capacity.observedConnections, 'observed connections'), + count(capacity.enforcedConnectionUnits, 'enforced connection units') + ) + const reserved = count(capacity.pendingControlReservations, 'pending control reservations') + return Math.max(0, pause - used - reserved) +} + +export async function checkSameCapHeadroom(config, overrides = {}) { + const fetchImpl = overrides.fetch ?? fetch + const wait = overrides.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))) + const token = overrides.token ?? process.env.ORCA_RELAY_ADMIN_ID_TOKEN + if (!token || token.length > 8_192) throw new Error('admin identity token is unavailable') + const cellStatus = async (cellId) => { + const { status } = await adminJson( + fetchImpl, + `${config.directorOrigin}/v1/admin/cell-status`, + token, + { v: 1, cellId }, + wait + ) + if (status?.cellId !== cellId) throw new Error('director status does not match the cell') + return status + } + const runtime = await adminJson( + fetchImpl, + `${config.cellOrigin}/v1/admin/runtime-status`, + token, + { v: 1 }, + wait + ) + if (runtime.role !== 'cell' || runtime.cellId !== config.cellId) { + throw new Error('runtime status does not match the cell') + } + const targetHosts = count(runtime.runtime?.controls, 'target host controls') + const targetRegion = (await cellStatus(config.cellId)).region ?? null + const cells = [] + for (const cellId of config.generalCells) { + if (cellId === config.cellId) continue + const status = await cellStatus(cellId) + cells.push({ cellId, region: status.region ?? null, freeSlots: freeSlots(status) }) + } + const total = (filter) => + cells.filter(filter).reduce((sum, cell) => sum + cell.freeSlots, 0) + const sameRegion = (cell) => targetRegion !== null && cell.region === targetRegion + const free = total(() => true) + const allowedHosts = Math.floor(free * MAX_HEADROOM_FRACTION) + return { + cellId: config.cellId, + region: targetRegion, + targetHosts, + freeSlots: free, + sameRegionFreeSlots: total(sameRegion), + otherRegionFreeSlots: total((cell) => !sameRegion(cell)), + allowedHosts, + sufficient: targetHosts <= allowedHosts, + cells + } +} + +export async function main(argv = process.argv.slice(2)) { + const result = await checkSameCapHeadroom(parseHeadroomArguments(argv)) + process.stdout.write(`${JSON.stringify({ event: 'relay_same_cap_headroom', ...result })}\n`) + if (!result.sufficient) { + throw new Error( + `${result.targetHosts} hosts on ${result.cellId} exceed ` + + `${MAX_HEADROOM_FRACTION * 100}% of ${result.freeSlots} free general-cell slots` + ) + } +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 + }) +} diff --git a/cloud/dev/scripts/check-relay-same-cap-headroom.test.mjs b/cloud/dev/scripts/check-relay-same-cap-headroom.test.mjs new file mode 100644 index 000000000000..66b5eda3bacb --- /dev/null +++ b/cloud/dev/scripts/check-relay-same-cap-headroom.test.mjs @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + checkSameCapHeadroom, + main, + parseHeadroomArguments +} from './check-relay-same-cap-headroom.mjs' + +const config = { + directorOrigin: 'https://relay.example.com', + cellOrigin: 'https://c28.relay.example.com', + cellId: 'production-gce-c28', + generalCells: ['production-gce-c28', 'production-gce-c29', 'production-gce-asia-c1'] +} + +function cell({ + region = 'us-central1', + pause = 840, + observed = 0, + enforced = observed, + pending = 0, + ...rest +} = {}) { + return { + enabled: true, + admissionState: 'general', + region, + connectionCapacity: { + normalAdmissionPause: pause, + observedConnections: observed, + enforcedConnectionUnits: enforced, + pendingControlReservations: pending, + heartbeatFresh: true + }, + ...rest + } +} + +function harness({ controls = 500, cells = {} } = {}) { + const statuses = { + 'production-gce-c28': cell({ observed: 800 }), + 'production-gce-c29': cell({ observed: 340 }), + 'production-gce-asia-c1': cell({ region: 'asia-east2', pause: 2_840, observed: 2_640 }), + ...cells + } + return async (url, options) => { + if (new URL(url).pathname === '/v1/admin/runtime-status') { + return Response.json({ role: 'cell', cellId: config.cellId, runtime: { controls } }) + } + const { cellId } = JSON.parse(options.body) + return Response.json({ v: 1, status: { cellId, ...statuses[cellId] } }) + } +} + +const run = async (fetch) => + await checkSameCapHeadroom(config, { fetch, token: 'masked-token', wait: async () => {} }) + +test('counts free general-cell slots by region and excludes the target', async () => { + const result = await run(harness({ controls: 560 })) + assert.deepEqual( + { ...result, cells: undefined }, + { + cellId: config.cellId, + region: 'us-central1', + targetHosts: 560, + freeSlots: 700, + sameRegionFreeSlots: 500, + otherRegionFreeSlots: 200, + allowedHosts: 560, + sufficient: true, + cells: undefined + } + ) + assert.deepEqual(result.cells.map((entry) => entry.cellId), [ + 'production-gce-c29', + 'production-gce-asia-c1' + ]) +}) + +test('free slots subtract the larger of observed and enforced use plus reservations', async () => { + for (const [state, free] of [ + [{ observed: 340, enforced: 400 }, 640], + [{ observed: 400, enforced: 340 }, 640], + [{ observed: 340, pending: 60 }, 640], + [{ observed: 800, pending: 60 }, 200] + ]) { + const result = await run(harness({ cells: { 'production-gce-c29': cell(state) } })) + assert.equal(result.freeSlots, free) + } +}) + +test('rejects malformed capacity counts', async () => { + await assert.rejects( + run(harness({ + cells: { 'production-gce-c29': cell({ pending: null }) } + })), + /pending control reservations is invalid/ + ) +}) + +test('refuses a cell whose hosts exceed 80% of the free slots', async () => { + const result = await run(harness({ controls: 561 })) + assert.equal(result.sufficient, false) +}) + +test('a stale, full, or non-general cell offers no slots', async () => { + for (const blocked of [ + cell({ + connectionCapacity: { + normalAdmissionPause: 840, + observedConnections: 0, + enforcedConnectionUnits: 0, + pendingControlReservations: 0, + heartbeatFresh: false + } + }), + cell({ connectionCapacity: null }), + cell({ admissionState: 'migration-only' }), + cell({ enabled: false }), + cell({ observed: 900 }) + ]) { + const result = await run(harness({ cells: { 'production-gce-c29': blocked } })) + assert.equal(result.freeSlots, 200) + assert.equal(result.sufficient, false) + } +}) + +test('no general cells means no headroom', async () => { + const result = await checkSameCapHeadroom( + { ...config, generalCells: [] }, + { fetch: harness({ controls: 1 }), token: 'masked-token', wait: async () => {} } + ) + assert.equal(result.freeSlots, 0) + assert.equal(result.sufficient, false) +}) + +test('rejects a runtime or director answer for another cell', async () => { + const base = harness() + await assert.rejects( + run(async (url, options) => + new URL(url).pathname === '/v1/admin/runtime-status' + ? Response.json({ role: 'cell', cellId: 'production-gce-c1', runtime: { controls: 1 } }) + : await base(url, options)), + /runtime status does not match/ + ) + await assert.rejects( + run(async (url, options) => + new URL(url).pathname === '/v1/admin/cell-status' + ? Response.json({ v: 1, status: { cellId: 'production-gce-c1' } }) + : await base(url, options)), + /director status does not match/ + ) +}) + +test('parses the general cell list, including none', () => { + const base = [ + '--director-origin', 'https://relay.example.com', + '--cell-origin', 'https://c28.relay.example.com', + '--cell-id', 'production-gce-c28' + ] + assert.deepEqual( + parseHeadroomArguments([...base, '--general-cells', 'a-c1,b-c2']).generalCells, + ['a-c1', 'b-c2'] + ) + assert.deepEqual(parseHeadroomArguments([...base, '--general-cells', 'none']).generalCells, []) + assert.throws(() => parseHeadroomArguments([...base, '--general-cells', 'a,a']), /invalid/) + assert.throws(() => parseHeadroomArguments(base), /missing --general-cells/) +}) + +test('main prints the numbers and fails on insufficient headroom', async (t) => { + const writes = [] + t.mock.method(process.stdout, 'write', (text) => writes.push(text)) + t.mock.method(globalThis, 'fetch', harness({ controls: 700 })) + process.env.ORCA_RELAY_ADMIN_ID_TOKEN = 'masked-token' + t.after(() => { + delete process.env.ORCA_RELAY_ADMIN_ID_TOKEN + }) + await assert.rejects( + main([ + '--director-origin', config.directorOrigin, + '--cell-origin', config.cellOrigin, + '--cell-id', config.cellId, + '--general-cells', config.generalCells.join(',') + ]), + /700 hosts on production-gce-c28 exceed 80% of 700 free general-cell slots/ + ) + const line = JSON.parse(writes.join('')) + assert.equal(line.event, 'relay_same_cap_headroom') + assert.equal(line.allowedHosts, 560) +}) diff --git a/cloud/dev/scripts/operate-relay-asia-admission.mjs b/cloud/dev/scripts/operate-relay-asia-admission.mjs index b02b7c04cda0..fea4f073b70f 100644 --- a/cloud/dev/scripts/operate-relay-asia-admission.mjs +++ b/cloud/dev/scripts/operate-relay-asia-admission.mjs @@ -19,16 +19,21 @@ const SHAPES = { production: { directorOrigin: 'https://relay.onorca.dev', domain: 'relay.onorca.dev', - allCells: ['production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30'], + allCells: [ + 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30', + 'production-gce-c31' + ], // The launch set was registered together; each later cell registers alone beside it. registrationWaves: [ ['production-gce-c27', 'production-gce-c28', 'production-gce-c29'], - ['production-gce-c30'] + ['production-gce-c30'], + ['production-gce-c31'] ], promotionWaves: [ ['production-gce-c27'], ['production-gce-c28', 'production-gce-c29'], - ['production-gce-c30'] + ['production-gce-c30'], + ['production-gce-c31'] ] } } diff --git a/cloud/dev/scripts/operate-relay-asia-admission.test.mjs b/cloud/dev/scripts/operate-relay-asia-admission.test.mjs index 0a84a3ca20de..2465f970cae2 100644 --- a/cloud/dev/scripts/operate-relay-asia-admission.test.mjs +++ b/cloud/dev/scripts/operate-relay-asia-admission.test.mjs @@ -526,23 +526,30 @@ function admissionArguments(environment, mode, cellIds) { test('accepts only reviewed Asia admission waves', () => { const accepted = [ ['inspect', 'production-gce-c27,production-gce-c28,production-gce-c29'], - ['inspect', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30'], + ['inspect', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31'], ['inspect', 'production-gce-c30'], + ['inspect', 'production-gce-c31'], ['verify', 'production-gce-c27,production-gce-c28,production-gce-c29'], ['verify', 'production-gce-c30'], + ['verify', 'production-gce-c31'], ['initialize', 'production-gce-c27,production-gce-c28,production-gce-c29'], ['register', 'production-gce-c27,production-gce-c28,production-gce-c29'], ['register', 'production-gce-c30'], ['registered', 'production-gce-c30'], + ['register', 'production-gce-c31'], + ['registered', 'production-gce-c31'], ['promote', 'production-gce-c27'], ['promote', 'production-gce-c28,production-gce-c29'], ['promote', 'production-gce-c30'], ['recover-promotion', 'production-gce-c30'], + ['promote', 'production-gce-c31'], + ['recover-promotion', 'production-gce-c31'], ['rollback', 'production-gce-c27'], ['rollback', 'production-gce-c28,production-gce-c29'], ['rollback', 'production-gce-c30'], + ['rollback', 'production-gce-c31'], ['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29'], - ['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30'] + ['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31'] ] for (const [mode, cellIds] of accepted) { assert.deepEqual( @@ -553,15 +560,21 @@ test('accepts only reviewed Asia admission waves', () => { } const rejected = [ ['initialize', 'production-gce-c30'], - ['initialize', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30'], - ['register', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30'], + ['initialize', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31'], + ['register', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31'], + ['register', 'production-gce-c30,production-gce-c31'], ['register', 'production-gce-c29,production-gce-c30'], - ['registered', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30'], + ['registered', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31'], + ['inspect', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30'], + ['verify', 'production-gce-c30,production-gce-c31'], ['verify', 'production-gce-c27,production-gce-c30'], ['promote', 'production-gce-c27,production-gce-c30'], ['promote', 'production-gce-c28,production-gce-c29,production-gce-c30'], - ['promote', 'production-gce-c31'], + ['promote', 'production-gce-c30,production-gce-c31'], + ['promote', 'production-gce-c32'], ['rollback', 'production-gce-c27,production-gce-c30'], + ['rollback', 'production-gce-c30,production-gce-c31'], + ['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30'], ['rollback', 'production-gce-c28,production-gce-c29,production-gce-c30'], ['rollback', 'production-gce-c29'], ['register', 'staging-gce-c4'] @@ -601,6 +614,25 @@ test('registers C30 alone beside the general launch cells', async () => { assert.deepEqual(subject.selector().membership.general, launchCells) }) +test('registers C31 alone beside the general C27-C30', async () => { + const general = [...launchCells, 'production-gce-c30'] + const subject = harness({ + generation: 11, + membership: { existingOnly: [], migrationOnly: [], general: [...general] } + }) + const result = await operateRelayAsiaAdmission({ + environment: 'production', mode: 'register', cells: ['production-gce-c31'], + expectedGeneration: 11, imageDigest: digest, attemptId: 'asia_register_c31', token: 'not-logged' + }, subject) + const request = subject.requests.find(({ path }) => path.endsWith('/add-migration-cells')) + assert.deepEqual(request.body.cells, [{ + cellId: 'production-gce-c31', cellUrl: 'https://c31.relay.onorca.dev', region: 'asia-east2', + capacityRequests: 6_000, connectionHardCap: 3_000, connectionUnobservedBound: 60 + }]) + assert.deepEqual(result.states, { 'production-gce-c31': 'migration-only' }) + assert.deepEqual(subject.selector().membership.general, general) +}) + test('requires the C27 canary to be general before promoting C30', async () => { const selector = (general) => ({ generation: 10, diff --git a/cloud/dev/scripts/prepare-relay-asia-topology-input.mjs b/cloud/dev/scripts/prepare-relay-asia-topology-input.mjs index 1199b3df3772..19d14e4de6cb 100644 --- a/cloud/dev/scripts/prepare-relay-asia-topology-input.mjs +++ b/cloud/dev/scripts/prepare-relay-asia-topology-input.mjs @@ -16,12 +16,14 @@ const SHAPES = { 'production-gce-c27': 'asia-east2-a', 'production-gce-c28': 'asia-east2-b', 'production-gce-c29': 'asia-east2-c', - 'production-gce-c30': 'asia-east2-a' + 'production-gce-c30': 'asia-east2-a', + 'production-gce-c31': 'asia-east2-b' }, // The launch set, then each later additive cell; a plan targets one wave, never live cells. waves: [ ['production-gce-c27', 'production-gce-c28', 'production-gce-c29'], - ['production-gce-c30'] + ['production-gce-c30'], + ['production-gce-c31'] ] } } diff --git a/cloud/dev/scripts/prepare-relay-asia-topology-input.test.mjs b/cloud/dev/scripts/prepare-relay-asia-topology-input.test.mjs index 6cfd04eaf946..1e911ae34835 100644 --- a/cloud/dev/scripts/prepare-relay-asia-topology-input.test.mjs +++ b/cloud/dev/scripts/prepare-relay-asia-topology-input.test.mjs @@ -11,7 +11,8 @@ const productionCells = () => Object.fromEntries([ [27, 'asia-east2-a'], [28, 'asia-east2-b'], [29, 'asia-east2-c'], - [30, 'asia-east2-a'] + [30, 'asia-east2-a'], + [31, 'asia-east2-b'] ].map(([ordinal, zone]) => [`production-gce-c${ordinal}`, { hostname: `c${ordinal}`, region: 'asia-east2', zone, machine_type: 'e2-standard-4', boot_disk_gb: 30, @@ -46,6 +47,13 @@ test('accepts the additive C30 wave without re-planning the launch cells', () => assert.equal(result.relay_gce_cells['production-gce-c30'].zone, 'asia-east2-a') }) +test('accepts the additive C31 wave in the next zone of the rotation', () => { + const result = prepareRelayAsiaTopologyInput({ existingCells: productionCells(), + existingAdditionalRegions: additionalRegions, environment: 'production', + cellIds: 'production-gce-c31', image }) + assert.equal(result.relay_gce_cells['production-gce-c31'].zone, 'asia-east2-b') +}) + // Reads the committed file so a reviewed-shape constant cannot drift from what the plan reads. test('matches every committed production Asia cell entry', () => { const tfvars = readFileSync( @@ -64,10 +72,11 @@ test('matches every committed production Asia cell entry', () => { } committed[cellId] = cell } - // Each wave is pinned on its own: C30 launches on the director's digest, not C27's. + // Each wave is pinned on its own: C30 and C31 launch on the director's digest, not C27's. for (const wave of [ 'production-gce-c27,production-gce-c28,production-gce-c29', - 'production-gce-c30' + 'production-gce-c30', + 'production-gce-c31' ]) { const committedImage = committed[wave.split(',')[0]].image assert.doesNotThrow(() => prepareRelayAsiaTopologyInput({ @@ -109,7 +118,8 @@ test('rejects an uncommitted subnet or cell, partial wave, wrong image, and drif 'production-gce-c27,production-gce-c30', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30', 'production-gce-c30,production-gce-c30', - 'production-gce-c31' + 'production-gce-c30,production-gce-c31', + 'production-gce-c32' ]) { assert.throws(() => prepareRelayAsiaTopologyInput({ existingCells: productionCells(), existingAdditionalRegions: additionalRegions, diff --git a/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs b/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs index ee30b6340fab..491d7f61160b 100644 --- a/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs +++ b/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs @@ -97,6 +97,7 @@ describe('production Relay capacity cell admission', () => { it('admits the same-cap Asia and migration-only cells only under the same-cap allowlist', () => { for (const cellId of [ 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30', + 'production-gce-c31', // Migration-only canaries: the US-only capacity rollout never touches them either. 'production-gce-c17', 'production-gce-c18' ]) { @@ -115,7 +116,7 @@ describe('production Relay capacity cell admission', () => { paceWindowMs: 0 }) } - for (const cellId of ['production-gce-c12', 'production-gce-c31']) { + for (const cellId of ['production-gce-c12', 'production-gce-c32']) { const hostname = cellId.slice('production-gce-'.length) assert.throws(() => parseProductionCapacityCellArguments([ '--director-origin', 'https://relay.onorca.dev', diff --git a/cloud/dev/scripts/probe-relay-rehome-trust.mjs b/cloud/dev/scripts/probe-relay-rehome-trust.mjs index 7fef67cd2373..a34986902898 100644 --- a/cloud/dev/scripts/probe-relay-rehome-trust.mjs +++ b/cloud/dev/scripts/probe-relay-rehome-trust.mjs @@ -2,8 +2,8 @@ import { pathToFileURL } from 'node:url' import { fetchAdminOnceMore } from './relay-admin-transient-retry.mjs' // Every cell that carries the rehome identity: the sixteen US cells and the -// four asia-east2 cells that drain mis-homed hosts back the other way. -const PRODUCTION_CELL = /^production-gce-c(?:7|8|9|10|13|14|15|16|19|20|21|22|23|24|25|26|27|28|29|30)$/ +// five asia-east2 cells that drain mis-homed hosts back the other way. +const PRODUCTION_CELL = /^production-gce-c(?:7|8|9|10|13|14|15|16|19|20|21|22|23|24|25|26|27|28|29|30|31)$/ const DIRECTOR_ORIGIN = 'https://relay.onorca.dev' export function parseRehomeTrustProbeArguments(argv, environment = process.env) { diff --git a/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs b/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs index f7c9696aeeba..63287a8405bf 100644 --- a/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs +++ b/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs @@ -114,7 +114,8 @@ test('fails when both trust-probe attempts return a transient 503', async () => test('approves the asia-east2 rehome sources and still rejects unlisted cells', () => { for (const cellId of [ - 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30' + 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30', + 'production-gce-c31' ]) { const parsed = parseRehomeTrustProbeArguments( argv.map((value) => (value === 'production-gce-c7' ? cellId : value)), @@ -122,7 +123,7 @@ test('approves the asia-east2 rehome sources and still rejects unlisted cells', ) assert.equal(parsed.cellId, cellId) } - for (const cellId of ['production-gce-c1', 'production-gce-c17', 'production-gce-c31']) { + for (const cellId of ['production-gce-c1', 'production-gce-c17', 'production-gce-c32']) { assert.throws( () => parseRehomeTrustProbeArguments( diff --git a/cloud/dev/scripts/relay-asia-rollout-evidence.mjs b/cloud/dev/scripts/relay-asia-rollout-evidence.mjs index cf7d53c52bc7..e063d44b4f1a 100644 --- a/cloud/dev/scripts/relay-asia-rollout-evidence.mjs +++ b/cloud/dev/scripts/relay-asia-rollout-evidence.mjs @@ -11,7 +11,8 @@ const C27 = 'production-gce-c27' // Each canary proves its own cell under production load; C28/C29 promotion consumes only C27's. const PRODUCTION_CANARIES = { [C27]: { kind: 'production-c27-canary', origin: 'https://c27.relay.onorca.dev' }, - 'production-gce-c30': { kind: 'production-c30-canary', origin: 'https://c30.relay.onorca.dev' } + 'production-gce-c30': { kind: 'production-c30-canary', origin: 'https://c30.relay.onorca.dev' }, + 'production-gce-c31': { kind: 'production-c31-canary', origin: 'https://c31.relay.onorca.dev' } } const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/ const SHA_PATTERN = /^[a-f0-9]{40}$/ @@ -85,7 +86,12 @@ export function buildStagingEvidence(input) { } assertLoadReports(launch, expectedLoad) const metrics = runtimeMetrics(input.logs, start, end, STAGING_CELL) - assertPassingRuntimeMetrics(metrics, 'staging proof', 1) + assertPassingRuntimeMetrics(metrics, 'staging proof') + // Staging is quiet, so its one intentional sticky fallback must be the only one. + if ( + number(metrics.regionFallbacks, 'regionFallbacks') !== 1 || + number(metrics.usRegionFallbacks, 'usRegionFallbacks') !== 1 + ) throw new Error('staging proof regionFallbacks did not match the intentional probes') const minimumConcurrentSplices = expectedLoad.splices - expectedLoad.wedgedReaders if ( metrics.targetControlsMax < expectedLoad.controls || @@ -303,6 +309,11 @@ export function buildProductionCanaryEvidence(input) { // Directors show a steady relay_cells lock and pool-wait baseline unrelated to the canary cell. const metrics = runtimeMetrics(input.logs, start, end, input.cellId, { gateDirectorDatabase: false }) assertPassingRuntimeMetrics(metrics, `${canary.label} canary`) + // Fallbacks are keyed by the host's target region. US-targeted ones come from unhinted or + // US-preferring hosts an Asia cell cannot cause, so they are recorded but not gated. + if (number(metrics.asiaRegionFallbacks, 'asiaRegionFallbacks') !== 0) { + throw new Error(`${canary.label} canary asiaRegionFallbacks must be zero`) + } metrics.cloudSqlBackendsMax = cloudSqlMaximum(input.cloudSql, start, end) assertPassingCanary(metrics, input.cellId) return { @@ -370,6 +381,12 @@ function runtimeMetrics(logs, start, end, targetCellId, { gateDirectorDatabase = regionFallbacks: directorPayloads.reduce( (total, payload) => total + sumMap(payload.regionFallbacksDelta, 'region fallbacks'), 0 ), + asiaRegionFallbacks: directorPayloads.reduce( + (total, payload) => total + number( + payload.regionFallbacksDelta?.['asia-east2'] ?? 0, + 'Asia region fallbacks' + ), 0 + ), usRegionFallbacks: directorPayloads.reduce( (total, payload) => total + number( payload.regionFallbacksDelta?.['us-central1'] ?? 0, @@ -424,16 +441,10 @@ function runtimeMetrics(logs, start, end, targetCellId, { gateDirectorDatabase = } } -function assertPassingRuntimeMetrics(metrics, label, expectedRegionFallbacks = 0) { +function assertPassingRuntimeMetrics(metrics, label) { if (number(metrics.asiaSelections, 'Asia selections') < 1) { throw new Error(`${label} observed no Asia selections`) } - if ( - number(metrics.regionFallbacks, 'regionFallbacks') !== expectedRegionFallbacks || - number(metrics.usRegionFallbacks, 'usRegionFallbacks') !== expectedRegionFallbacks - ) { - throw new Error(`${label} regionFallbacks did not match the intentional probes`) - } for (const key of [ 'unavailableRegions', 'relaySqlFailures', 'databasePoolWaitingMax' ]) { diff --git a/cloud/dev/scripts/relay-asia-rollout-evidence.test.mjs b/cloud/dev/scripts/relay-asia-rollout-evidence.test.mjs index 1da4c9628798..70a68e808969 100644 --- a/cloud/dev/scripts/relay-asia-rollout-evidence.test.mjs +++ b/cloud/dev/scripts/relay-asia-rollout-evidence.test.mjs @@ -330,7 +330,7 @@ test('rejects a C27 canary without a real control and splice', () => { for (const [label, mutation, message] of [ ['Asia selections', (input) => input.logs.forEach((entry) => { entry.jsonPayload.selectedRegionsDelta = {} }), /no Asia selections/], - ['region fallbacks', (input) => { input.logs[0].jsonPayload.regionFallbacksDelta = { 'asia-east2': 1 } }, /regionFallbacks/], + ['Asia-targeted region fallbacks', (input) => { input.logs[0].jsonPayload.regionFallbacksDelta = { 'asia-east2': 1 } }, /C27 canary asiaRegionFallbacks must be zero/], ['unavailable regions', (input) => { input.logs[0].jsonPayload.unavailableRegionsDelta = { 'asia-east2': 1 } }, /unavailableRegions/], ['C27 SQL failures', (input) => { input.logs[1].jsonPayload.sqlFailuresDelta = 1 }, /C27 canary relaySqlFailures must be zero/], ['C27 pool waiting', (input) => { input.logs[1].jsonPayload.databasePoolWaiting = 1 }, /databasePoolWaitingMax/], @@ -389,6 +389,33 @@ test('builds and verifies a C30 canary from C30 runtime metrics and placement', ), /evidence kind is invalid/) }) +test('builds a C31 canary that only C31 placement satisfies', () => { + const c31 = 'production-gce-c31' + const evidence = buildProductionCanaryEvidence(canaryInput({}, c31)) + assert.equal(evidence.kind, 'production-c31-canary') + assert.equal(verifyRolloutEvidence( + evidence, workflowRun(evidence), + verifyExpected('production-c31-canary', { cellIds: [c31], selectorGeneration: 9 }) + ), evidence) + const onC30 = canaryInput({}, c31) + onC30.loadReport.assignedCellOrigins = ['https://c30.relay.onorca.dev'] + assert.throws(() => buildProductionCanaryEvidence(onC30), /C31 canary load was not placed only on C31/) +}) + +test('records but does not gate organic US-targeted fallbacks during a canary', () => { + // Mirrors C31's 2026-10-01 canary: US-targeted fallbacks, none targeting Asia. + const input = canaryInput({}, 'production-gce-c31') + input.logs.filter((entry) => entry.jsonPayload.role === 'director').forEach((entry) => { + entry.jsonPayload.regionFallbacksDelta = { 'us-central1': 24 } + }) + const evidence = buildProductionCanaryEvidence(input) + assert.equal(evidence.metrics.regionFallbacks, 144) + assert.equal(evidence.metrics.usRegionFallbacks, 144) + assert.equal(evidence.metrics.asiaRegionFallbacks, 0) + input.logs[0].jsonPayload.regionFallbacksDelta = { 'us-central1': 24, 'asia-east2': 1 } + assert.throws(() => buildProductionCanaryEvidence(input), /C31 canary asiaRegionFallbacks must be zero/) +}) + test('rejects a C30 canary that C30 did not serve', () => { const onLaunchCell = canaryInput({}, c30) onLaunchCell.loadReport.assignedCellOrigins = ['https://c27.relay.onorca.dev'] diff --git a/cloud/dev/scripts/relay-asia-topology-workflow.test.mjs b/cloud/dev/scripts/relay-asia-topology-workflow.test.mjs index d8965f028691..2e594aae3e40 100644 --- a/cloud/dev/scripts/relay-asia-topology-workflow.test.mjs +++ b/cloud/dev/scripts/relay-asia-topology-workflow.test.mjs @@ -44,7 +44,8 @@ test('accepts only the reviewed Asia topology waves', () => { [ 'staging:staging-gce-c4', 'production:production-gce-c27,production-gce-c28,production-gce-c29', - 'production:production-gce-c30' + 'production:production-gce-c30', + 'production:production-gce-c31' ] ) }) diff --git a/cloud/dev/scripts/relay-cloud-sql-connection-budget.test.mjs b/cloud/dev/scripts/relay-cloud-sql-connection-budget.test.mjs index 333969e5684d..3c6d1bed172f 100644 --- a/cloud/dev/scripts/relay-cloud-sql-connection-budget.test.mjs +++ b/cloud/dev/scripts/relay-cloud-sql-connection-budget.test.mjs @@ -7,12 +7,12 @@ import { } from './relay-cloud-sql-connection-budget.mjs' test('production shared consumers keep allowance and reserve below the ceiling', () => { - // cells: 20 pools at 10 (200) + the four asia-east2 pools at 16 (64). + // cells: 20 pools at 10 (200) + the five asia-east2 pools at 16 (80). const report = readRelayCloudSqlConnectionBudget() - assert.deepEqual(report.consumers, { cells: 264, directors: 15, auth: 20, api: 50 }) - assert.deepEqual(report.asia, { cells: 4, poolMax: 16 }) - assert.equal(report.configuredMaximum, 349) + assert.deepEqual(report.consumers, { cells: 280, directors: 15, auth: 20, api: 50 }) + assert.deepEqual(report.asia, { cells: 5, poolMax: 16 }) + assert.equal(report.configuredMaximum, 365) assert.equal(report.rolloutOverlap.relayDirectorCandidate, 30) assert.equal(report.rolloutOverlap.apiCandidate, 65) assert.equal(report.rolloutOverlap.authCandidate, 35) @@ -22,10 +22,10 @@ test('production shared consumers keep allowance and reserve below the ceiling', assert.equal(report.maintenanceAdminAllowance, 5) assert.equal(report.explicitReserve, 10) assert.equal(report.usableCeiling, 490) - assert.equal(report.operatingMaximum, 419) - assert.equal(report.remainingWithinUsableCeiling, 71) - assert.equal(report.budgetedTotal, 429) - assert.equal(report.unallocated, 71) + assert.equal(report.operatingMaximum, 435) + assert.equal(report.remainingWithinUsableCeiling, 55) + assert.equal(report.budgetedTotal, 445) + assert.equal(report.unallocated, 55) assert.equal(report.withinBudget, true) }) diff --git a/cloud/dev/scripts/relay-evidence-code-provenance.mjs b/cloud/dev/scripts/relay-evidence-code-provenance.mjs index 233a8139b853..76badb39cbd1 100644 --- a/cloud/dev/scripts/relay-evidence-code-provenance.mjs +++ b/cloud/dev/scripts/relay-evidence-code-provenance.mjs @@ -25,6 +25,7 @@ export const TRUSTED_EVIDENCE_CODE_PATHS = [ relayTreePath('dev/scripts/relay-production-same-cap-wave.mjs'), relayTreePath('dev/scripts/relay-repository.mjs'), // Every other script those jobs run against live production. + relayTreePath('dev/scripts/check-relay-same-cap-headroom.mjs'), relayTreePath('dev/scripts/infra.mjs'), relayTreePath('dev/scripts/operate-relay-regional-rehome.mjs'), relayTreePath('dev/scripts/prepare-relay-production-capacity-canary.mjs'), diff --git a/cloud/dev/scripts/relay-production-same-cap-wave.mjs b/cloud/dev/scripts/relay-production-same-cap-wave.mjs index 212f3f1d16d4..4929df748a32 100644 --- a/cloud/dev/scripts/relay-production-same-cap-wave.mjs +++ b/cloud/dev/scripts/relay-production-same-cap-wave.mjs @@ -12,6 +12,7 @@ export const SAME_CAP_CELLS = [ 'production-gce-c19', 'production-gce-c20', 'production-gce-c21', 'production-gce-c22', 'production-gce-c23', 'production-gce-c24', 'production-gce-c25', 'production-gce-c26', 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30', + 'production-gce-c31', ...SAME_CAP_MIGRATION_ONLY_CELLS ] diff --git a/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs b/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs index 8a3cfc240ef4..ef549149ebb0 100644 --- a/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs +++ b/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs @@ -56,12 +56,19 @@ test('requires one canary or a bounded reviewed batch', () => { rollbackDigest, confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c30` }).cells, ['production-gce-c30']) - assert.throws(() => validateSameCapWave({ + assert.deepEqual(validateSameCapWave({ mode: 'canary-apply', cellIds: 'production-gce-c31', targetDigest, rollbackDigest, confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c31` + }).cells, ['production-gce-c31']) + assert.throws(() => validateSameCapWave({ + mode: 'canary-apply', + cellIds: 'production-gce-c32', + targetDigest, + rollbackDigest, + confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c32` }), /cells/) }) @@ -107,9 +114,10 @@ test('the wave workflow chains exactly ten serial cell jobs', () => { assert.doesNotMatch(dispatch, /\n cell_11:/) }) -test('lists only C17 and C18 as migration-only now that C30 is promoted', () => { +test('lists only C17 and C18 as migration-only now that C31 is promoted', () => { assert.deepEqual(SAME_CAP_MIGRATION_ONLY_CELLS, ['production-gce-c17', 'production-gce-c18']) assert.equal(SAME_CAP_CELLS.includes('production-gce-c30'), true) + assert.equal(SAME_CAP_CELLS.includes('production-gce-c31'), true) }) test('rolls the migration-only cells but never mixes the two classes in one wave', () => { @@ -154,6 +162,27 @@ test('rolls the migration-only cells but never mixes the two classes in one wave confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${asiaMixed}`, canaryRunId: '42' }), /all general or all migration-only/) + // C31 is general since its 2026-10-01 promotion, so it rolls beside C30 but never C17/C18. + assert.equal(entryAdmission('production-gce-c31'), 'general') + assert.equal(selectorWaveDelta('production-gce-c31'), 2) + const asiaPromoted = 'production-gce-c30,production-gce-c31' + assert.deepEqual(validateSameCapWave({ + mode: 'batch-apply', + cellIds: asiaPromoted, + targetDigest, + rollbackDigest, + confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${asiaPromoted}`, + canaryRunId: '42' + }).cells, ['production-gce-c30', 'production-gce-c31']) + const c31Mixed = 'production-gce-c31,production-gce-c18' + assert.throws(() => validateSameCapWave({ + mode: 'batch-apply', + cellIds: c31Mixed, + targetDigest, + rollbackDigest, + confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${c31Mixed}`, + canaryRunId: '42' + }), /all general or all migration-only/) // A mixed wave has no single selector delta for its later cells to offset from. const mixed = 'production-gce-c7,production-gce-c17' assert.throws(() => validateSameCapWave({ diff --git a/cloud/dev/scripts/relay-same-cap-job-mode-conditions.test.mjs b/cloud/dev/scripts/relay-same-cap-job-mode-conditions.test.mjs new file mode 100644 index 000000000000..90e4cc3f8409 --- /dev/null +++ b/cloud/dev/scripts/relay-same-cap-job-mode-conditions.test.mjs @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { readRelayWorkflow } from './relay-repository.mjs' + +const parent = readRelayWorkflow('deploy-relay-production-same-cap.yml') +const job = readRelayWorkflow('deploy-relay-production-same-cap-job.yml') + +function jobAcceptedModes() { + const match = /\[\[ "\$\{DEPLOY_MODE\}" =~ \^\(([a-z|-]+)\)\$ \]\]/.exec(job) + assert.ok(match, 'the job no longer validates DEPLOY_MODE') + return new Set(match[1].split('|')) +} + +// Mirrors the gate step's dispatch-mode to job-mode mapping. +function parentJobModes() { + const options = /mode:\n\s+description:[^\n]*\n(?:\s+[a-z]+:[^\n]*\n)*?\s+options: \[([^\]]+)\]/ + .exec(parent) + assert.ok(options, 'the parent has no dispatch mode options') + const collapsed = /if \[\[ "\$\{MODE\}" =~ \^\(([a-z|-]+)\)\$ \]\]; then\n\s+echo 'job-mode=([a-z]+)'/ + .exec(parent) + assert.ok(collapsed, 'the parent no longer collapses apply modes into one job mode') + const collapsedModes = new Set(collapsed[1].split('|')) + return new Set( + options[1].split(',').map((mode) => mode.trim()).map((mode) => + collapsedModes.has(mode) ? collapsed[2] : mode + ) + ) +} + +function stepCondition(name) { + const start = job.indexOf(`- name: ${name}\n`) + assert.notEqual(start, -1, `job has no step named ${name}`) + const step = job.slice(start, job.indexOf('\n - ', start + 1)) + const condition = /^ {8}if: \$\{\{ (.+) \}\}$/m.exec(step) + assert.ok(condition, `${name} has no if condition`) + return condition[1] +} + +// Just enough of the expression grammar for the step conditions this file compares. +function evaluate(expression, context) { + const tokens = expression.match(/'[^']*'|[A-Za-z_.]+|==|!=|&&|\|\||[()]/g) + assert.equal(tokens.join(''), expression.replaceAll(' ', ''), `unparsed: ${expression}`) + let index = 0 + const value = () => { + const token = tokens[index++] + if (token === '(') { + const inner = or() + assert.equal(tokens[index++], ')') + return inner + } + if (token.startsWith("'")) return token.slice(1, -1) + assert.ok(Object.hasOwn(context, token), `unknown operand ${token}`) + return context[token] + } + const comparison = () => { + const left = value() + if (tokens[index] !== '==' && tokens[index] !== '!=') return left + const operator = tokens[index++] + const right = value() + return operator === '==' ? left === right : left !== right + } + const and = () => { + let result = comparison() + while (tokens[index] === '&&') { + index++ + result = comparison() && result + } + return result + } + const or = () => { + let result = and() + while (tokens[index] === '||') { + index++ + result = or() || result + } + return result + } + const result = or() + assert.equal(index, tokens.length, `unparsed tail: ${expression}`) + return result +} + +test('the parent passes only modes the job accepts', () => { + assert.deepEqual(parentJobModes(), jobAcceptedModes()) +}) + +// A comparison against a mode the job never receives is constant, so its step is dead (#24259). +test('every job mode comparison names a mode the job can receive', () => { + const accepted = jobAcceptedModes() + const compared = [...job.matchAll(/inputs\.mode\s*[!=]=\s*'([^']*)'/g)].map(([, mode]) => mode) + assert.ok(compared.length > 0) + for (const mode of compared) assert.ok(accepted.has(mode), `job compares against ${mode}`) +}) + +test('the headroom gate runs whenever the drain runs, and in verify', () => { + const headroom = stepCondition("Require free general-cell slots for the selected cell's hosts") + const drain = stepCondition('Reversibly isolate and drain only the selected cell') + for (const mode of parentJobModes()) { + for (const resume of ['true', 'false']) { + const context = { 'inputs.mode': mode, 'env.ROLLBACK_RESUME': resume } + if (evaluate(drain, context)) { + assert.ok(evaluate(headroom, context), `drain without headroom check: ${mode}/${resume}`) + } + } + } + assert.ok(evaluate(headroom, { 'inputs.mode': 'verify', 'env.ROLLBACK_RESUME': 'false' })) +}) diff --git a/cloud/dev/scripts/relay-same-cap-script-census.test.mjs b/cloud/dev/scripts/relay-same-cap-script-census.test.mjs index ff2c265ee726..77a006cec80f 100644 --- a/cloud/dev/scripts/relay-same-cap-script-census.test.mjs +++ b/cloud/dev/scripts/relay-same-cap-script-census.test.mjs @@ -291,7 +291,7 @@ describe('same-cap roll scripts accept every same-cap cell', () => { assert.equal(String(cellShape(cellId).cap), tfvarsHardCap(cellId), cellId) } assert.equal(resolveCellShape('production-gce-c12').status, 1) - assert.equal(resolveCellShape('production-gce-c31').status, 1) + assert.equal(resolveCellShape('production-gce-c32').status, 1) }) diff --git a/cloud/dev/scripts/relay-same-cap-shadow-gate-verdict.mjs b/cloud/dev/scripts/relay-same-cap-shadow-gate-verdict.mjs index dc2ec33f1f39..68d72264ee2b 100644 --- a/cloud/dev/scripts/relay-same-cap-shadow-gate-verdict.mjs +++ b/cloud/dev/scripts/relay-same-cap-shadow-gate-verdict.mjs @@ -24,7 +24,8 @@ export const FLEET_POOL_CELL_IDS = [ 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', - 'production-gce-c30' + 'production-gce-c30', + 'production-gce-c31' ] export const SHADOW_GATE_THRESHOLDS = { diff --git a/cloud/dev/scripts/relay-same-cap-shadow-gate.test.mjs b/cloud/dev/scripts/relay-same-cap-shadow-gate.test.mjs index a206b7f80dfa..41e7ba32db82 100644 --- a/cloud/dev/scripts/relay-same-cap-shadow-gate.test.mjs +++ b/cloud/dev/scripts/relay-same-cap-shadow-gate.test.mjs @@ -271,7 +271,8 @@ function productionLikeEntries() { ...metricSamples({ cellId: 'production-gce-c28', from: '2026-09-20T20:20:00Z', count: 20 }), ...metricSamples({ cellId: 'production-gce-c27', from: '2026-09-20T20:20:00Z', count: 20 }), ...metricSamples({ cellId: 'production-gce-c29', from: '2026-09-20T20:20:00Z', count: 20 }), - ...metricSamples({ cellId: 'production-gce-c30', from: '2026-09-20T20:20:00Z', count: 20 }) + ...metricSamples({ cellId: 'production-gce-c30', from: '2026-09-20T20:20:00Z', count: 20 }), + ...metricSamples({ cellId: 'production-gce-c31', from: '2026-09-20T20:20:00Z', count: 20 }) ] } @@ -310,9 +311,10 @@ function gcloudSeam(entries = productionLikeEntries()) { } } -test('reads every promoted asia-east2 cell as fleet pool, C30 included', () => { +test('reads every promoted asia-east2 cell as fleet pool, C31 included', () => { assert.deepEqual(FLEET_POOL_CELL_IDS, [ - 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30' + 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30', + 'production-gce-c31' ]) }) @@ -331,7 +333,8 @@ test('a healthy roll reads as PASS and names the instance it proved serving', as 'director503', 'fleetPool:production-gce-c27', 'fleetPool:production-gce-c29', - 'fleetPool:production-gce-c30' + 'fleetPool:production-gce-c30', + 'fleetPool:production-gce-c31' ]) // Every read carries explicit bounds: --freshness does not bind on these logs. for (const { filter } of seam.calls) { diff --git a/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs b/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs index 67f30aa6d2b8..67225f4d8690 100644 --- a/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs +++ b/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs @@ -40,6 +40,8 @@ const launchDigest = '5aedbca5c86de24c8b4d4bf7e3b444b76c712f281ede916cb9d90f70ca const asiaCells = ['production-gce-c27', 'production-gce-c28', 'production-gce-c29'] // C30 launches after the launch cells rolled, so it pins the director's digest instead. const c30Digest = '4158d8a2e18e9caec439d257f0c1e45d92ffea8c0262f057b2f08c76a134bcf0' +// C31 launches on the digest the director served when it was declared. +const c31Digest = 'f30b5cb1ec52b6b6145efecfa1b8be9e3d309403beffd8abcc64197a2087e269' function cellBlock(tfvars, cellId) { const start = tfvars.indexOf(`"${cellId}"`) @@ -50,13 +52,14 @@ function cellBlock(tfvars, cellId) { const productionCell = (cellId) => cellBlock(productionTfvars, cellId) // Scoped to C4 by name: staging C3 serves this digest too since its 2026-09-03 re-pin. -test('pins staging C4 and the launch Asia cells to one image, and C30 to the director image', () => { +test('pins staging C4 and the launch Asia cells to one image, and C30/C31 to director images', () => { assert.match(cellBlock(stagingTfvars, 'staging-gce-c4'), new RegExp(`relay@sha256:${launchDigest}"`)) for (const cellId of asiaCells) { assert.match(productionCell(cellId), new RegExp(`relay@sha256:${launchDigest}"`), cellId) } assert.match(recoveryWorkflow, new RegExp(`TARGET_IMAGE_DIGEST: sha256:${launchDigest}`)) assert.match(productionCell('production-gce-c30'), new RegExp(`relay@sha256:${c30Digest}"`)) + assert.match(productionCell('production-gce-c31'), new RegExp(`relay@sha256:${c31Digest}"`)) }) test('refreshes only empty staging C4 through the trusted capacity identity', () => { diff --git a/cloud/dev/scripts/validate-relay-asia-topology-plan.mjs b/cloud/dev/scripts/validate-relay-asia-topology-plan.mjs index 0a7027718522..c41ce736a236 100644 --- a/cloud/dev/scripts/validate-relay-asia-topology-plan.mjs +++ b/cloud/dev/scripts/validate-relay-asia-topology-plan.mjs @@ -19,11 +19,13 @@ const CELL_SHAPES = { 'production-gce-c27': 'asia-east2-a', 'production-gce-c28': 'asia-east2-b', 'production-gce-c29': 'asia-east2-c', - 'production-gce-c30': 'asia-east2-a' + 'production-gce-c30': 'asia-east2-a', + 'production-gce-c31': 'asia-east2-b' }, waves: [ ['production-gce-c27', 'production-gce-c28', 'production-gce-c29'], - ['production-gce-c30'] + ['production-gce-c30'], + ['production-gce-c31'] ] }, staging: { diff --git a/cloud/dev/scripts/validate-relay-asia-topology-plan.test.mjs b/cloud/dev/scripts/validate-relay-asia-topology-plan.test.mjs index 77071a94fcee..82692b9d537e 100644 --- a/cloud/dev/scripts/validate-relay-asia-topology-plan.test.mjs +++ b/cloud/dev/scripts/validate-relay-asia-topology-plan.test.mjs @@ -96,15 +96,16 @@ const productionConfig = { environment: 'production', cells: ['production-gce-c30'], image: productionImage } -// C30 joins a live Asia region: the network is a no-op and the existing C27 route is preserved. -function productionC30Plan() { +// A later cell joins a live Asia region: the network is a no-op and the existing C27 route is preserved. +function productionWavePlan(hostname = 'c30', zone = 'asia-east2-a') { const plan = JSON.parse(JSON.stringify(resources) .replaceAll('onorca-cloud-staging/', 'onorca-cloud/') .replaceAll('orca-cloud-staging-relay-gce', 'orca-cloud-relay-gce') - .replaceAll('staging-gce-c4', 'production-gce-c30') - .replaceAll('relay-gce-c4', 'relay-gce-c30') - .replaceAll('cell-c4', 'cell-c30') - .replaceAll('c4.relay-staging.onorca.dev', 'c30.relay.onorca.dev') + .replaceAll('staging-gce-c4', `production-gce-${hostname}`) + .replaceAll('relay-gce-c4', `relay-gce-${hostname}`) + .replaceAll('cell-c4', `cell-${hostname}`) + .replaceAll('c4.relay-staging.onorca.dev', `${hostname}.relay.onorca.dev`) + .replaceAll('asia-east2-a', zone) .replaceAll("'10'", "'16'")) for (const network of plan.slice(0, 3)) { network.change.actions = ['no-op'] @@ -124,23 +125,23 @@ function productionC30Plan() { test('accepts the additive production C30 wave at the 16-connection Asia pool', () => { assert.deepEqual( - validateRelayAsiaTopologyPlan({ resource_changes: productionC30Plan() }, productionConfig), + validateRelayAsiaTopologyPlan({ resource_changes: productionWavePlan() }, productionConfig), { environment: 'production', cells: ['production-gce-c30'], changes: 4 } ) - const staleShape = productionC30Plan() + const staleShape = productionWavePlan() staleShape[3].change.after.metadata_startup_script = staleShape[3].change.after.metadata_startup_script.replace("'16'", "'10'") assert.throws( () => validateRelayAsiaTopologyPlan({ resource_changes: staleShape }, productionConfig), /reviewed Asia cell shape/ ) - const wrongZone = productionC30Plan() + const wrongZone = productionWavePlan() wrongZone[4].change.after.zone = 'asia-east2-b' assert.throws( () => validateRelayAsiaTopologyPlan({ resource_changes: wrongZone }, productionConfig), /fixed-one Asia MIG shape/ ) - const liveCellTouched = productionC30Plan() + const liveCellTouched = productionWavePlan() liveCellTouched.push(create('google_compute_instance_template.relay_gce_cell["production-gce-c27"]')) assert.throws( () => validateRelayAsiaTopologyPlan({ resource_changes: liveCellTouched }, productionConfig), @@ -148,13 +149,25 @@ test('accepts the additive production C30 wave at the 16-connection Asia pool', ) }) +test('accepts the additive production C31 wave only in asia-east2-b', () => { + const c31Config = { ...productionConfig, cells: ['production-gce-c31'] } + assert.deepEqual( + validateRelayAsiaTopologyPlan({ resource_changes: productionWavePlan('c31', 'asia-east2-b') }, c31Config), + { environment: 'production', cells: ['production-gce-c31'], changes: 4 } + ) + assert.throws( + () => validateRelayAsiaTopologyPlan({ resource_changes: productionWavePlan('c31') }, c31Config), + /fixed-one Asia MIG shape/ + ) +}) + // The URL map pulls every cell's backend, MIG and template into a targeted plan. const liveCellResources = ['instance_template', 'instance_group_manager', 'backend_service'] .flatMap((kind) => ['production-gce-c1', 'production-gce-c27', 'production-gce-c28', 'production-gce-c29'] .map((cellId) => `google_compute_${kind}.relay_gce_cell["${cellId}"]`)) test('accepts live cells the URL map pulls in only while they stay unchanged', () => { - const plan = productionC30Plan() + const plan = productionWavePlan() for (const address of liveCellResources) plan.push({ address, change: { actions: ['no-op'] } }) assert.equal( validateRelayAsiaTopologyPlan({ resource_changes: plan }, productionConfig).changes, @@ -162,7 +175,7 @@ test('accepts live cells the URL map pulls in only while they stay unchanged', ( ) for (const address of liveCellResources) { for (const action of [['update'], ['delete'], ['create', 'delete'], ['delete', 'create']]) { - const drifted = productionC30Plan() + const drifted = productionWavePlan() drifted.push({ address, change: { actions: action } }) assert.throws( () => validateRelayAsiaTopologyPlan({ resource_changes: drifted }, productionConfig), @@ -180,7 +193,8 @@ test('accepts only a reviewed Asia topology wave', () => { for (const cellIds of [ 'production-gce-c27,production-gce-c28,production-gce-c29', 'production-gce-c29,production-gce-c27,production-gce-c28', - 'production-gce-c30' + 'production-gce-c30', + 'production-gce-c31' ]) { assert.doesNotThrow( () => parseRelayAsiaTopologyPlanArguments(argv('production', cellIds, productionImage)), @@ -192,7 +206,8 @@ test('accepts only a reviewed Asia topology wave', () => { 'production-gce-c27,production-gce-c30', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30', 'production-gce-c30,production-gce-c30', - 'production-gce-c31' + 'production-gce-c30,production-gce-c31', + 'production-gce-c32' ]) { assert.throws( () => parseRelayAsiaTopologyPlanArguments(argv('production', cellIds, productionImage)), diff --git a/cloud/dev/scripts/verify-relay-capacity-transition.mjs b/cloud/dev/scripts/verify-relay-capacity-transition.mjs index 0c297fdad530..0c819adc9b60 100644 --- a/cloud/dev/scripts/verify-relay-capacity-transition.mjs +++ b/cloud/dev/scripts/verify-relay-capacity-transition.mjs @@ -2,6 +2,7 @@ import { pathToFileURL } from 'node:url' import { fetchAdminOnceMore } from './relay-admin-transient-retry.mjs' const CAPACITY_PROTOCOL = 2 +const POLL_INTERVAL_MS = 5_000 function integer(value, name) { const parsed = Number(value) @@ -114,6 +115,13 @@ export function parseCapacityTransitionArguments(argv) { if (runtime === 'unavailable' && regionalRehomeProtocol !== undefined) { throw new Error('unavailable runtime cannot prove the regional rehome protocol') } + const paceWindowMs = values['pace-window-ms'] === undefined + ? undefined + : integer(values['pace-window-ms'], '--pace-window-ms') + const liveRestartSafe = values.activity === 'restart-safe' && runtime === 'required' + if ((paceWindowMs !== undefined) !== liveRestartSafe) { + throw new Error('--pace-window-ms is required exactly for restart-safe activity on a live runtime') + } return { directorOrigin: origin.origin, cellOrigin: cellOrigin.origin, @@ -125,6 +133,7 @@ export function parseCapacityTransitionArguments(argv) { runtime, expectedImageDigests, ...(regionalRehomeProtocol === undefined ? {} : { regionalRehomeProtocol }), + ...(paceWindowMs === undefined ? {} : { paceWindowMs }), hardCap, unobservedBound, timeoutMs: integer(values['timeout-ms'] ?? 180_000, '--timeout-ms') @@ -181,7 +190,7 @@ function offlineRollbackMatches(status, config) { restartBlockingReservedRequests <= 0 } -function directorActivityMatches(status, config, restartBlockingReservedRequests) { +function directorActivityMatches(status, config) { const durable = [ status.activityLeases, status.reservedRequests, @@ -197,17 +206,15 @@ function directorActivityMatches(status, config, restartBlockingReservedRequests status.connectionCapacity?.pendingControlReservations ] const restartSafe = config.activity !== 'restart-safe' || (() => { - // Only a positive remainder is unexplained; the other gates reject real work. - return integer( - status.restartBlockingActivityLeases, - 'restart-blocking activity leases' - ) === 0 && - integer( - status.restartBlockingActivityRequestUnits, - 'restart-blocking activity request units' - ) === 0 && - restartBlockingReservedRequests <= 0 && - integer(status.outgoingMigrations, 'outgoing migrations') === 0 && + // Leases lag hosts that left or cannot be placed, so the runtime's counters gate instead; + // still validated so a malformed director fails closed. + integer(status.restartBlockingActivityLeases, 'restart-blocking activity leases') + integer( + status.restartBlockingActivityRequestUnits, + 'restart-blocking activity request units' + ) + // Open migrations are pinned to this cell's incarnation, which a restart replaces. + return integer(status.outgoingMigrations, 'outgoing migrations') === 0 && integer(status.incomingMigrations, 'incoming migrations') === 0 })() const quiescent = [...durable, ...transient] @@ -315,6 +322,20 @@ function transitionObservation(runtime, status) { } } +// Director records of hosts that left or cannot be placed; reported, never gated on. +function strandedObservation(observation) { + const director = observation.director + return director === undefined + ? null + : { + restartBlockingActivityLeases: director.restartBlockingActivityLeases, + restartBlockingActivityRequestUnits: director.restartBlockingActivityRequestUnits, + restartBlockingReservedRequests: director.restartBlockingReservedRequests, + outgoingMigrations: director.outgoingMigrations, + incomingMigrations: director.incomingMigrations + } +} + function runtimeQuiescent(runtime, config) { if ( runtime.role !== 'cell' || @@ -345,16 +366,23 @@ function runtimeQuiescent(runtime, config) { counts.push(runtime.runtime.enforcedConnectionUnits) } const quiescent = counts.every((value) => integer(value, 'runtime connection count') === 0) - const restartSafe = config.activity !== 'restart-safe' || - [ - runtime.runtime?.preAuthConnections, - runtime.runtime?.inFlightConnections, - runtime.runtime?.reservedConnectionUnits, + // Total and pre-auth connections also count unauthenticated redials, which never stop while + // hosts have nowhere else to go and lose nothing on a restart. + const liveIdle = (values) => + values.every((value) => integer(value, 'live runtime count') === 0) + // Draining refuses every control and host proof, so with no session left an in-flight or + // reserved unit can only belong to a handshake the cell is about to refuse. + const restartSafe = config.activity !== 'restart-safe' || ( + liveIdle([ runtime.runtime?.controls, runtime.runtime?.splices, runtime.runtime?.pendingSplices, runtime.runtime?.queuedBytes - ].every((value) => integer(value, 'live runtime count') === 0) + ]) && + // Still validated on a draining cell so a malformed runtime fails closed. + (liveIdle([runtime.runtime?.inFlightConnections, runtime.runtime?.reservedConnectionUnits]) || + runtime.draining === true) + ) if ( config.heartbeat === 'fresh' && !capacityMatches({ connectionCapacity: runtime.connectionCapacity }, config) @@ -374,6 +402,8 @@ export async function verifyCapacityTransition(config, overrides = {}) { throw new Error('restart-safe activity requires migration-only admission and draining') } const fetchImpl = overrides.fetch ?? fetch + const progress = overrides.progress ?? + ((line) => process.stdout.write(`${JSON.stringify(line)}\n`)) const wait = overrides.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))) const now = overrides.now ?? Date.now const token = overrides.token ?? process.env.ORCA_RELAY_ADMIN_ID_TOKEN @@ -391,6 +421,10 @@ export async function verifyCapacityTransition(config, overrides = {}) { throw new Error('director is not capacity-protocol compatible') } const deadline = now() + config.timeoutMs + // A paced drain can refill the runtime until its window ends, so the quiet must outlast it. + // Each poll waits a full interval, so consecutive samples bound the quiet time from below. + const requiredRestartSafeSamples = + Math.max(Math.ceil((config.paceWindowMs ?? 0) / POLL_INTERVAL_MS) + 1, 2) let restartSafeSamples = 0 let lastObservation = { runtimeAvailable: false } for (;;) { @@ -429,10 +463,14 @@ export async function verifyCapacityTransition(config, overrides = {}) { const matches = runtime === null ? offlineRollbackMatches(status, config) : runtimeQuiescent(runtime, config) && - directorActivityMatches(status, config, restartBlockingReservedRequests) && + directorActivityMatches(status, config) && capacityMatches(status, config) && heartbeatMatches(status, config.heartbeat) - if (matches && (config.activity !== 'restart-safe' || restartSafeSamples === 1)) { + if ( + matches && + (config.activity !== 'restart-safe' || + restartSafeSamples + 1 >= requiredRestartSafeSamples) + ) { return { cellId: status.cellId, admissionState: status.admissionState, @@ -444,11 +482,11 @@ export async function verifyCapacityTransition(config, overrides = {}) { status.connectionCapacity?.heartbeatFresh ?? status.runtime?.heartbeatFresh ?? false, imageDigest: runtime?.imageDigest ?? null, ...(config.activity === 'restart-safe' - ? { restartBlockingReservedRequests } + ? { restartBlockingReservedRequests, stranded: strandedObservation(lastObservation) } : {}) } } - restartSafeSamples = matches ? 1 : 0 + restartSafeSamples = matches ? restartSafeSamples + 1 : 0 } else { restartSafeSamples = 0 } @@ -456,15 +494,29 @@ export async function verifyCapacityTransition(config, overrides = {}) { lastObservation = { ...lastObservation, restartSafeSamples, - requiredRestartSafeSamples: 2 + requiredRestartSafeSamples } + progress({ + event: 'relay_capacity_transition_restart_progress', + restartSafeSamples, + requiredRestartSafeSamples, + totalConnections: lastObservation.runtime?.totalConnections ?? null, + preAuthConnections: lastObservation.runtime?.preAuthConnections ?? null, + runtimeAvailable: lastObservation.runtimeAvailable, + admissionState: lastObservation.admissionState ?? null, + draining: lastObservation.draining ?? null, + runtime: lastObservation.runtime ?? null, + directorHeartbeatFresh: lastObservation.directorCapacity?.heartbeatFresh ?? + lastObservation.runtimeHeartbeatFresh ?? null, + stranded: strandedObservation(lastObservation) + }) } if (now() >= deadline) { throw new Error( `capacity transition verification timed out: ${JSON.stringify(lastObservation)}` ) } - await wait(5_000) + await wait(POLL_INTERVAL_MS) } } diff --git a/cloud/dev/scripts/verify-relay-capacity-transition.test.mjs b/cloud/dev/scripts/verify-relay-capacity-transition.test.mjs index e596ffbced7b..71d8d88b6a8b 100644 --- a/cloud/dev/scripts/verify-relay-capacity-transition.test.mjs +++ b/cloud/dev/scripts/verify-relay-capacity-transition.test.mjs @@ -135,7 +135,8 @@ test('parses a paired reviewed capacity', () => { '--activity', 'restart-safe', '--expected-image-digests', imageDigest, '--hard-cap', '1000', - '--unobserved-bound', '60' + '--unobserved-bound', '60', + '--pace-window-ms', '300000' ]), { ...config, @@ -144,6 +145,7 @@ test('parses a paired reviewed capacity', () => { activity: 'restart-safe', runtime: 'required', expectedImageDigests: [imageDigest], + paceWindowMs: 300_000, timeoutMs: 180_000 } ) @@ -572,7 +574,7 @@ test('restart-safe settling resets after data-plane admission appears', async () timeoutMs: 20_000 } const safe = harness({ active: 1, activityLeases: 0 }) - const unsafe = harness({ active: 1, activityLeases: 0, preAuthConnections: 1 }) + const unsafe = harness({ active: 1, activityLeases: 0, controls: 1 }) let runtimeReads = 0 let now = 0 const result = await verifyCapacityTransition(restartConfig, { @@ -593,7 +595,7 @@ test('restart-safe settling resets after data-plane admission appears', async () assert.equal(runtimeReads, 4) }) -test('restart-safe settling resets after director activity appears', async () => { +test('restart-safe settling resets after a migration opens', async () => { const restartConfig = { ...config, admission: 'migration-only', @@ -607,10 +609,11 @@ test('restart-safe settling resets after director activity appears', async () => restartBlockingReservedRequests: 0 }) const unsafe = harness({ - activityLeases: 840, - restartBlockingActivityLeases: 1, - restartBlockingActivityRequestUnits: 1, - restartBlockingReservedRequests: 1 + activityLeases: 839, + restartBlockingActivityLeases: 0, + restartBlockingActivityRequestUnits: 0, + restartBlockingReservedRequests: 0, + incomingMigrations: 1 }) let directorReads = 0 let runtimeReads = 0 @@ -764,7 +767,7 @@ test('negative restart reservation accounting cannot mask real work', async () = }, { fetch: harness({ - restartBlockingActivityLeases: 1, + controls: 1, restartBlockingReservedRequests: -1 }), token: 'masked-token' @@ -786,28 +789,13 @@ test('restart gate rejects live or durable cell work', async (t) => { ['splice', { active: 1, activityLeases: 0, splices: 1 }], ['pending splice', { active: 1, activityLeases: 0, pendingSplices: 1 }], ['queued data', { active: 1, activityLeases: 0, queuedBytes: 1 }], - ['pre-auth connection', { active: 1, activityLeases: 0, preAuthConnections: 1 }], - ['in-flight connection', { - activityLeases: 0, - inFlightConnections: 1, - enforcedConnectionUnits: 1 - }], - ['reserved data unit', { - active: 1, + ['control beside a reserved data unit', { + active: 2, activityLeases: 0, + controls: 1, reservedConnectionUnits: 1, - enforcedConnectionUnits: 2 + enforcedConnectionUnits: 3 }], - ['activity lease', { activityLeases: 1 }], - ['misaccounted pending control lease', { - activityLeases: 1, - activityRequestUnits: 2, - reservedRequests: 2, - restartBlockingActivityLeases: 0, - restartBlockingActivityRequestUnits: 1, - restartBlockingReservedRequests: 1 - }], - ['cell reservation', { reservedRequests: 1 }], ['outgoing migration', { outgoingMigrations: 1 }], ['incoming migration', { incomingMigrations: 1 }] ] @@ -818,7 +806,8 @@ test('restart gate rejects live or durable cell work', async (t) => { fetch: harness(state), token: 'masked-token' }), - /timed out/ + // A zero timeout can never collect two samples; the blocker must stop the first one. + /timed out.*"restartSafeSamples":0/ ) }) } @@ -1168,3 +1157,332 @@ test('fails when both director health attempts return a transient 503', async () ) assert.equal(healthCalls, 2) }) + +// Production-gce-c28 on 2026-10-01: drained to an empty runtime while four hosts with no free +// slot anywhere kept redialling and held director leases on it. +const strandedCell = { + activityLeases: 10, + activityRequestUnits: 10, + reservedRequests: 4, + restartBlockingActivityLeases: 4, + restartBlockingActivityRequestUnits: 4, + restartBlockingReservedRequests: -2, + pendingControlReservations: 6 +} +const pacedRestartConfig = { + ...config, + admission: 'migration-only', + activity: 'restart-safe', + paceWindowMs: 300_000, + timeoutMs: 1_200_000 +} + +function fakeClock() { + let now = 0 + return { + now: () => now, + wait: async (milliseconds) => { + now += milliseconds + } + } +} + +test('requires the drain pace window exactly for a live restart-safe wait', () => { + const base = [ + '--director-origin', 'https://relay.example.com', + '--cell-origin', 'https://c3.relay.example.com', + '--cell-id', 'staging-gce-c3', + '--heartbeat', 'either', + '--admission', 'migration-only', + '--draining', 'required' + ] + assert.equal( + parseCapacityTransitionArguments([ + ...base, '--activity', 'restart-safe', '--pace-window-ms', '300000' + ]).paceWindowMs, + 300_000 + ) + assert.throws( + () => parseCapacityTransitionArguments([...base, '--activity', 'restart-safe']), + /required exactly for restart-safe/ + ) + assert.throws( + () => parseCapacityTransitionArguments([ + ...base, '--activity', 'allowed', '--pace-window-ms', '300000' + ]), + /required exactly for restart-safe/ + ) + assert.throws( + () => parseCapacityTransitionArguments([ + ...base, '--activity', 'restart-safe', '--pace-window-ms', '-1' + ]), + /--pace-window-ms is invalid/ + ) +}) + +test('passes after one pace window of an empty runtime and reports stranded leases', async () => { + const lines = [] + let runtimeReads = 0 + const base = harness(strandedCell) + const result = await verifyCapacityTransition(pacedRestartConfig, { + fetch: async (url, options) => { + if (new URL(url).pathname === '/v1/admin/runtime-status') runtimeReads += 1 + return await base(url, options) + }, + token: 'masked-token', + progress: (line) => lines.push(line), + ...fakeClock() + }) + // One sample at each end of a full five-minute pace window. + assert.equal(runtimeReads, 61) + assert.deepEqual(result.stranded, { + restartBlockingActivityLeases: 4, + restartBlockingActivityRequestUnits: 4, + restartBlockingReservedRequests: -2, + outgoingMigrations: 0, + incomingMigrations: 0 + }) + // Every sample but the passing one prints its progress with the stranded counts. + assert.equal(lines.length, 60) + assert.ok(lines.every((line) => + line.event === 'relay_capacity_transition_restart_progress' && + line.requiredRestartSafeSamples === 61 && + line.stranded.restartBlockingActivityLeases === 4)) + assert.deepEqual(lines.slice(0, 2).map((line) => line.restartSafeSamples), [1, 2]) +}) + +test('the pace window resets on any non-zero runtime sample', async () => { + const quiet = harness(strandedCell) + const busy = harness({ ...strandedCell, controls: 1 }) + let runtimeReads = 0 + const lines = [] + await verifyCapacityTransition(pacedRestartConfig, { + fetch: async (url, options) => { + if (new URL(url).pathname === '/v1/admin/runtime-status') runtimeReads += 1 + return await (runtimeReads === 30 ? busy : quiet)(url, options) + }, + token: 'masked-token', + progress: (line) => lines.push(line), + ...fakeClock() + }) + assert.equal(runtimeReads, 91) + assert.deepEqual(lines.slice(28, 31).map((line) => line.restartSafeSamples), [29, 0, 1]) + await assert.rejects( + verifyCapacityTransition( + { ...pacedRestartConfig, timeoutMs: 295_000 }, + { fetch: quiet, token: 'masked-token', progress: () => {}, ...fakeClock() } + ), + /"restartSafeSamples":60,"requiredRestartSafeSamples":61/ + ) +}) + +test('restart-safe fails on live runtime work or an open migration regardless of leases', async (t) => { + for (const blocker of [ + { controls: 1 }, + { splices: 1 }, + { pendingSplices: 1 }, + { queuedBytes: 1 }, + { outgoingMigrations: 1 }, + { incomingMigrations: 1 } + ]) { + await t.test(Object.keys(blocker)[0], async () => { + await assert.rejects( + verifyCapacityTransition(pacedRestartConfig, { + fetch: harness({ ...strandedCell, ...blocker }), + token: 'masked-token', + progress: () => {}, + ...fakeClock() + }), + /timed out.*"restartSafeSamples":0/ + ) + }) + } +}) + +test('unauthenticated redials do not reset the pace window', async () => { + let runtimeReads = 0 + const lines = [] + const result = await verifyCapacityTransition(pacedRestartConfig, { + fetch: async (url, options) => { + if (new URL(url).pathname === '/v1/admin/runtime-status') runtimeReads += 1 + return await harness({ + ...strandedCell, + active: runtimeReads % 4, + enforcedConnectionUnits: 0, + preAuthConnections: runtimeReads % 2 + })(url, options) + }, + token: 'masked-token', + progress: (line) => lines.push(line), + ...fakeClock() + }) + assert.equal(runtimeReads, 61) + assert.equal(result.stranded.restartBlockingActivityLeases, 4) + assert.deepEqual( + lines.slice(0, 4).map((line) => [line.totalConnections, line.preAuthConnections]), + [[1, 1], [2, 0], [3, 1], [0, 0]] + ) +}) + +// c29 2026-10-01: a phone redialling the drained cell holds one physical socket and one +// reserved host-data unit until the cell refuses it; a host control redial holds an in-flight unit. +const refusedPhoneDial = { + active: 1, + preAuthConnections: 1, + reservedConnectionUnits: 1, + enforcedConnectionUnits: 2 +} +const refusedControlDial = { inFlightConnections: 1, enforcedConnectionUnits: 1 } + +test('a draining cell reaches the pace window through refused redials', async () => { + let runtimeReads = 0 + const lines = [] + const result = await verifyCapacityTransition(pacedRestartConfig, { + fetch: async (url, options) => { + if (new URL(url).pathname === '/v1/admin/runtime-status') runtimeReads += 1 + const redial = runtimeReads % 3 === 0 + ? refusedPhoneDial + : runtimeReads % 3 === 1 ? refusedControlDial : {} + return await harness({ ...strandedCell, ...redial })(url, options) + }, + token: 'masked-token', + progress: (line) => lines.push(line), + ...fakeClock() + }) + assert.equal(runtimeReads, 61) + assert.equal(result.cellId, config.cellId) + assert.deepEqual(lines.slice(0, 3).map((line) => line.restartSafeSamples), [1, 2, 3]) +}) + +test('a refused redial cannot hide a persistent control on a draining cell', async () => { + let runtimeReads = 0 + const lines = [] + await assert.rejects( + verifyCapacityTransition(pacedRestartConfig, { + fetch: async (url, options) => { + if (new URL(url).pathname === '/v1/admin/runtime-status') runtimeReads += 1 + const redial = runtimeReads % 2 === 0 ? refusedPhoneDial : {} + return await harness({ ...strandedCell, ...redial, controls: 1 })(url, options) + }, + token: 'masked-token', + progress: (line) => lines.push(line), + ...fakeClock() + }), + /timed out.*"restartSafeSamples":0/ + ) + assert.ok(lines.length > 61) + assert.ok(lines.every((line) => line.restartSafeSamples === 0 && line.runtime.controls === 1)) +}) + +test('a runtime that is not draining never passes restart-safe', async (t) => { + for (const [name, state] of [ + ['idle', {}], + ['refused phone dial', refusedPhoneDial], + ['refused control dial', refusedControlDial] + ]) { + await t.test(name, async () => { + await assert.rejects( + verifyCapacityTransition(pacedRestartConfig, { + fetch: harness({ ...strandedCell, ...state, draining: 'forbidden' }), + token: 'masked-token', + progress: () => {}, + ...fakeClock() + }), + /timed out.*"draining":false.*"restartSafeSamples":0/ + ) + }) + } +}) + +test('quiescent transitions still reject refused redials', async (t) => { + for (const [name, state] of [ + ['refused phone dial', refusedPhoneDial], + ['refused control dial', refusedControlDial] + ]) { + await t.test(name, async () => { + await assert.rejects( + verifyCapacityTransition(config, { fetch: harness(state), token: 'masked-token' }), + /timed out/ + ) + }) + } +}) + +test('restart progress prints every counter the gate reads', async () => { + const lines = [] + await assert.rejects( + verifyCapacityTransition( + { ...pacedRestartConfig, timeoutMs: 0 }, + { + fetch: harness({ + ...strandedCell, + active: 7, + preAuthConnections: 1, + inFlightConnections: 2, + reservedConnectionUnits: 3, + enforcedConnectionUnits: 12, + controls: 4, + splices: 5, + pendingSplices: 6, + queuedBytes: 8, + incomingMigrations: 9, + outgoingMigrations: 10 + }), + token: 'masked-token', + progress: (line) => lines.push(line), + ...fakeClock() + } + ), + /timed out/ + ) + assert.equal(lines.length, 1) + assert.deepEqual(lines[0], { + event: 'relay_capacity_transition_restart_progress', + restartSafeSamples: 0, + requiredRestartSafeSamples: 61, + totalConnections: 7, + preAuthConnections: 1, + runtimeAvailable: true, + admissionState: 'migration-only', + draining: true, + runtime: { + totalConnections: 7, + preAuthConnections: 1, + inFlightConnections: 2, + reservedConnectionUnits: 3, + enforcedConnectionUnits: 12, + controls: 4, + splices: 5, + pendingSplices: 6, + queuedBytes: 8 + }, + directorHeartbeatFresh: true, + stranded: { + restartBlockingActivityLeases: 4, + restartBlockingActivityRequestUnits: 4, + restartBlockingReservedRequests: -2, + outgoingMigrations: 10, + incomingMigrations: 9 + } + }) +}) + +test('a draining cell still fails closed on malformed handshake counters', async (t) => { + for (const [name, state] of [ + ['negative in-flight', { inFlightConnections: -1 }], + ['fractional reserved', { reservedConnectionUnits: 0.5 }], + ['non-numeric reserved', { reservedConnectionUnits: 'unknown' }] + ]) { + await t.test(name, async () => { + await assert.rejects( + verifyCapacityTransition(pacedRestartConfig, { + fetch: harness({ ...strandedCell, ...state }), + token: 'masked-token', + progress: () => {}, + ...fakeClock() + }), + /live runtime count is invalid/ + ) + }) + } +}) diff --git a/cloud/docs/relay-workflows.md b/cloud/docs/relay-workflows.md index 55e4c59cdef6..4b2a71db57e4 100644 --- a/cloud/docs/relay-workflows.md +++ b/cloud/docs/relay-workflows.md @@ -166,11 +166,13 @@ atomically register the new cells as migration-only, binding every mutation to the exact live selector generation and a durable attempt ID. Deploy and verify the director configuration only after registration, then promote C27 alone before C28/C29. -The production Asia set is C27-C30. C27-C29 launched as one wave; C30 is an additive wave of its -own at the same shape. Its plan names C30's template, MIG, and backend plus the shared URL map, and -the URL map pulls every existing cell's backend, MIG, and template into the plan. Committed images -lag what same-cap rolls serve, so the workflow first reads each non-target cell's served image out -of its live template in state and plans that cell at it. It reads the committed cell map from a +The production Asia set is C27-C31. C27-C29 launched as one wave; C30 and C31 are each an additive +wave of their own at the same shape, and C31 takes `asia-east2-b` so the five cells spread 2/2/1 +across the zones. The C30 steps below apply to C31 unchanged, with C31 in place of C30. C30's plan +names its template, MIG, and backend plus the shared URL map, and the URL map pulls every existing +cell's backend, MIG, and template into the plan. Committed images lag what same-cap rolls serve, +so the workflow first reads each non-target cell's served image out of its live template in state +and plans that cell at it. It reads the committed cell map from a no-refresh, unlocked plan over the same targets, not `terraform console`. Console evaluates every output against state, so `relay_gce_cell_deployments` wraps each per-cell resource lookup in `try`: until C30's topology apply, console succeeds and that output shows C30 with null MIG, @@ -185,10 +187,16 @@ C30: the evidence must show the canary control was placed on C30, read C30's own and bind the selector generation, and any failure returns C30 to migration-only. The SQL-failure and database-pool rules read C30's own metrics only. Director values are recorded under `director`-prefixed names but do not fail the canary, because directors show a steady baseline of -`relay_cells` lock refusals and pool waits unrelated to C30. C30 was promoted to general on +`relay_cells` lock refusals and pool waits unrelated to C30. Director region fallbacks are keyed by +the host's target region, and the canary fails on any Asia-targeted one. US-targeted fallbacks are +recorded but not gated: they are placement-lane requests from unhinted or US-preferring hosts, which +an Asia cell cannot cause. Staging still requires exactly its one +intentional fallback. C30 was promoted to general on 2026-09-23, so the same-cap job now rolls it as a general cell and the shadow gate's fleet pool list reads it beside C27-C29. A later Asia cell stays in the same-cap migration-only list and out of the fleet pool list until its own promotion, then moves to both together, as its own reviewed wave. +C31 followed that path and was promoted to general on 2026-10-01, so it is now a same-cap general +cell and in the fleet pool list beside C27-C30. Rollback returns Asia cells to migration-only; it does not destroy the network or use existing-only. The production topology dispatch remains unavailable until the @@ -401,9 +409,22 @@ targeted Terraform plan, and per-cell heartbeat/admission oracle are unchanged. sets and the two migration-only US 600/60 cells, C17 and C18, without changing a cell's connection shape. Use `canary-apply` for exactly one cell. A successful canary seals its commit, target and rollback digests, selector generation, and durable rehome generation; -`batch-apply` accepts only that same authority and rolls two to four cells sequentially. Each cell is -isolated, drained to two restart-safe samples, replaced from a targeted saved plan, and restored only -after a new incarnation reports the exact digest, cap, heartbeat, and rehome protocol. The durable +`batch-apply` accepts only that same authority and rolls two to four cells sequentially. Both apply +modes and `rollback` first refuse a cell whose hosts (controls) exceed 80% of the free slots on the +other fresh general cells, since drained hosts with nowhere to go keep redialling and pin the cell. +`verify` runs the same read-only check, so it reports the headroom answer before an apply is +dispatched; a rollback that resumes after its restart drains nothing and skips it. A cell's free +slots are its normal admission pause minus the larger of observed connections and enforced units, +minus outstanding control reservations; each moved host also brings its splices, which the 20% +margin covers. Each cell is isolated, drained until restart-safe, replaced +from a targeted saved plan, and restored only after a new incarnation reports the exact digest, cap, +heartbeat, and rehome protocol. Restart-safe means the cell runtime itself carries nothing live (no +controls, in-flight connections, reserved connection units, splices, or queued bytes) and no +migration is open, for a whole drain pace window, which every live restart-safe call must pass. +Pre-auth and total connections are printed but do not reset the window: they include +unauthenticated redials that lose nothing on a restart. Director activity leases left by hosts that already went or cannot +be placed do not hold the restart; every `relay_capacity_transition_restart_progress` sample and +the final verified line report them under `stranded`. The durable worker must remain disabled throughout. The post-restart trust check is application-mediated by the director; the workflow never receives or mints a director or stamped-cell runtime token. A failure keeps only the selected cell migration-only, while the exact rollback digest remains dispatchable via diff --git a/cloud/infra/terraform/README.md b/cloud/infra/terraform/README.md index a41c609ddd53..f6fbe82eb464 100644 --- a/cloud/infra/terraform/README.md +++ b/cloud/infra/terraform/README.md @@ -332,7 +332,7 @@ cell templates/MIGs/backends, and exact shared URL-map host additions. It rejects deletes, replacements, loss of an existing host route, US-resource changes, and unrelated drift. Do not add production C27-C29 until the compatible image has been published and each entry can pin its immutable -digest. A later cell, such as C30, is its own reviewed wave. The shared URL map +digest. A later cell, such as C30 or C31, is its own reviewed wave. The shared URL map pulls every live cell into its plan, so the workflow plans each live cell at the image its state template already serves, and the validator rejects any change to a cell outside the wave. diff --git a/cloud/infra/terraform/environments/production.tfvars b/cloud/infra/terraform/environments/production.tfvars index e8f2f47485fd..a2394260f015 100644 --- a/cloud/infra/terraform/environments/production.tfvars +++ b/cloud/infra/terraform/environments/production.tfvars @@ -407,6 +407,20 @@ relay_gce_cells = { connection_hard_cap = 3000 connection_unobserved_bound = 60 } + "production-gce-c31" = { + hostname = "c31" + region = "asia-east2" + zone = "asia-east2-b" + machine_type = "e2-standard-4" + boot_disk_gb = 30 + boot_image = "https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-21" + capacity_requests = 6000 + database_pool_max = 16 # 176 ms from us-central1 Postgres saturates 10 (94-156 waiters). + image = "us-central1-docker.pkg.dev/onorca-cloud/orca-cloud/relay@sha256:f30b5cb1ec52b6b6145efecfa1b8be9e3d309403beffd8abcc64197a2087e269" + initially_enabled = false + connection_hard_cap = 3000 + connection_unobserved_bound = 60 + } } relay_region_rehome_source_cell_ids = [ @@ -430,7 +444,8 @@ relay_region_rehome_source_cell_ids = [ "production-gce-c27", "production-gce-c28", "production-gce-c29", - "production-gce-c30" + "production-gce-c30", + "production-gce-c31" ] # Slack #orca-relay-alerts, created out of band on 2026-08-05. Declared here because an apply diff --git a/cloud/package.json b/cloud/package.json index c029b06d6faf..9a722b75acaf 100644 --- a/cloud/package.json +++ b/cloud/package.json @@ -21,7 +21,7 @@ "load:relay:recovery-gate": "node dev/scripts/run-relay-recovery-wave-gate.mjs", "ops:relay": "pnpm --filter @orca-cloud/relay-ops dev", "pretest": "node --test dev/scripts/capture-terraform-plan-baseline.test.mjs dev/scripts/operate-relay-asia-admission.test.mjs dev/scripts/prepare-relay-asia-director-cells.test.mjs dev/scripts/prepare-relay-asia-topology-input.test.mjs dev/scripts/production-cloud-sql-rollout-lock.test.mjs dev/scripts/read-relay-serving-regional-placement-version.test.mjs dev/scripts/relay-asia-rollout-evidence.test.mjs dev/scripts/relay-asia-topology-workflow.test.mjs dev/scripts/relay-cloud-sql-connection-budget.test.mjs dev/scripts/relay-load-reader-evidence.test.mjs dev/scripts/relay-lock-contention-alerts.test.mjs dev/scripts/relay-region-hint-metrics.test.mjs dev/scripts/relay-staging-deploy-identity.test.mjs dev/scripts/sanitize-relay-asia-admission-result.test.mjs dev/scripts/terraform-root-partition.test.mjs dev/scripts/validate-relay-asia-topology-plan.test.mjs ../.github/actions/cloud-sql-rollout-lease/action-contract.test.mjs ../.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs", - "test": "pnpm -r test && node --test dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/push-gateway-workflow.test.mjs dev/scripts/push-gateway-recovery.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs dev/scripts/relay-admin-transient-retry.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-same-cap-script-census.test.mjs dev/scripts/relay-same-cap-shadow-gate.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs", + "test": "pnpm -r test && node --test dev/scripts/check-relay-same-cap-headroom.test.mjs dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/push-gateway-workflow.test.mjs dev/scripts/push-gateway-recovery.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs dev/scripts/relay-admin-transient-retry.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-same-cap-job-mode-conditions.test.mjs dev/scripts/relay-same-cap-script-census.test.mjs dev/scripts/relay-same-cap-shadow-gate.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs", "typecheck": "pnpm -r typecheck" }, "devDependencies": { diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts index c4f58fa521ce..e0765a4873ba 100644 --- a/config/build-plugins/plain-node-entry-guard.ts +++ b/config/build-plugins/plain-node-entry-guard.ts @@ -20,7 +20,6 @@ type OutputChunk = Rollup.OutputChunk export const CLI_MAIN_ENTRY_NAMES = [ 'agent-hooks/managed-agent-hook-controls', 'orca-profiles/profile-index-store', - 'codex/managed-home-shell-preflight', 'claude-accounts/keychain', ...[ 'access', diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 0b15e696f46f..7117e64df6db 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -189,8 +189,12 @@ module.exports = { // extraResources. Shipping them in app.asar bloats the desktop bundle. '!src{,/**/*}', '!out/orcad{,/**/*}', - '!out/orcad-template{,/**/*}', + // Template, node-pty prebuilds and their work dirs: headless build outputs, not desktop code. + '!out/orcad-*{,/**/*}', '!out/.orcad-*{,/**/*}', + // Why: the pinned Node a local orcad build references (~120 MB) and its download cache. + '!out/runtimes{,/**/*}', + '!out/node-runtime-cache{,/**/*}', '!config{,/**/*}', '!docs{,/**/*}', '!mobile{,/**/*}', @@ -407,7 +411,6 @@ module.exports = { verifyPackagedPluginResources(resourcesDir) finalizePackagedRipgrep(resourcesDir) chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName) - chmodMacServeSimHelpers(resourcesDir, context.electronPlatformName) for (const filename of readdirSync(resourcesDir)) { if (!filename.startsWith('agent-browser-')) { continue @@ -718,23 +721,6 @@ function chmodUnixCliLaunchers(resourcesDir, electronPlatformName) { } } -function chmodMacServeSimHelpers(resourcesDir, electronPlatformName) { - if (electronPlatformName !== 'darwin') { - return - } - const helperPaths = [ - join(resourcesDir, 'serve-sim', 'bin', 'serve-sim-bin'), - join(resourcesDir, 'serve-sim', 'dist', 'simcam', 'serve-sim-camera-helper'), - join(resourcesDir, 'node_modules', 'serve-sim', 'bin', 'serve-sim-bin'), - join(resourcesDir, 'node_modules', 'serve-sim', 'dist', 'simcam', 'serve-sim-camera-helper') - ] - for (const helperPath of helperPaths) { - if (existsSync(helperPath)) { - chmodSync(helperPath, 0o755) - } - } -} - async function signMacComputerUseHelper(helperAppPath, packager) { if (!existsSync(helperAppPath)) { if (isMacRelease) { diff --git a/config/relay-assets/node-pty-1.1.0-windows-pty-teardown-patch.cjs b/config/relay-assets/node-pty-1.1.0-windows-pty-teardown-patch.cjs index 1e908754dc65..2dfc48a7bf41 100644 --- a/config/relay-assets/node-pty-1.1.0-windows-pty-teardown-patch.cjs +++ b/config/relay-assets/node-pty-1.1.0-windows-pty-teardown-patch.cjs @@ -80,9 +80,9 @@ const { join, resolve } = require('node:path') * * - the pnpm patch does not cross the SSH boundary -- the remote `npm install` yields upstream's * unpatched node-pty; - * - the orcad prebuild matrix has no win32 entry (`MATRIX_SLOTS`, - * `config/scripts/build-orcad-prebuilds.mjs`), so no Windows binary is ever compiled from - * patched source to ship; + * - the orcad prebuild matrix now compiles win32 slots from patched source + * (`config/scripts/build-orcad-prebuilds.mjs`), but only standalone orcad consumes them; + * nothing ships them to a Windows relay host yet; * - a relay asset CAN patch native source and rebuild on the host -- that is exactly what * `node-pty-1.1.0-master-cloexec-patch.cjs` does -- but it returns * `skipped:unsupported-platform` for anything but linux/darwin. Extending it to win32 means diff --git a/config/scripts/build-mobile-web-app-bundle.mjs b/config/scripts/build-mobile-web-app-bundle.mjs index 1bfad89b6e73..09f5f913a94e 100644 --- a/config/scripts/build-mobile-web-app-bundle.mjs +++ b/config/scripts/build-mobile-web-app-bundle.mjs @@ -139,9 +139,17 @@ export const MOBILE_WEB_APP_ROOT_RESET = * Inputs and textareas: Chromium rings a focused one (`:focus-visible` matches every focused * text field); no native TextInput paints one, and the caret and the IME already mark focus. * Those only: a button reached by a hardware keyboard keeps the browser's ring. + * + * Text: native `Text` is unselectable unless `selectable`; react-native-web's is selectable. On + * iOS a hold on it raises WebKit's selection over the page's own long press; on Android the + * selection's `selectionchange` and `touchcancel` end a held press. `body`, not `#root`: modals + * portal to `document.body`. Fields opt back in, callout too, since WebKit inherits it. */ export const MOBILE_WEB_APP_NATIVE_PARITY_STYLE = - '' + '' const PAGE_ASYNC_STORAGE_MODULE = join( mobileDir, diff --git a/config/scripts/build-orcad-bun.mjs b/config/scripts/build-orcad-bun.mjs deleted file mode 100644 index cddd766e6cc9..000000000000 --- a/config/scripts/build-orcad-bun.mjs +++ /dev/null @@ -1,176 +0,0 @@ -#!/usr/bin/env node - -import { createHash } from 'node:crypto' -import { - chmodSync, - copyFileSync, - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - readdirSync, - rmSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { basename, join, resolve } from 'node:path' -import { orcadBunRuntimeFilename } from '../../src/shared/orcad-artifacts.ts' -import { - ORCAD_BUN_RELEASE_ASSETS, - ORCAD_BUN_VERSION, - orcadBunReleaseUrl -} from '../../src/shared/orcad-bun-runtime.ts' -import { runProcessSync } from './script-child-process.mjs' -import { getZipExtractorCommand } from './zip-extractor-command.mjs' - -const root = resolve(import.meta.dirname, '../..') -const cacheRoot = join(root, 'out', '.orcad-bun-runtime', `v${ORCAD_BUN_VERSION}`) - -export function currentTarget() { - if (process.platform === 'darwin') { - return `darwin-${process.arch}` - } - if (process.platform === 'win32') { - return `win32-${process.arch}` - } - if (process.platform !== 'linux') { - throw new Error(`Unsupported Bun platform: ${process.platform}`) - } - const glibc = process.report?.getReport()?.header?.glibcVersionRuntime - return `linux-${process.arch}-${glibc ? 'glibc' : 'musl'}` -} - -function argument(name) { - const index = process.argv.indexOf(name) - return index === -1 ? null : process.argv[index + 1] -} - -async function download(url, destination) { - const response = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(120_000) }) - if (!response.ok) { - await response.body?.cancel() - throw new Error(`Bun download failed: ${response.status} ${response.statusText}`) - } - writeFileSync(destination, new Uint8Array(await response.arrayBuffer())) -} - -function sha256(path) { - return createHash('sha256').update(readFileSync(path)).digest('hex') -} - -export function bunExecutableName(target) { - return target.startsWith('win32-') ? 'bun.exe' : 'bun' -} - -export function findBunExecutable(rootDir, target) { - const expected = bunExecutableName(target) - const entries = readdirSync(rootDir, { recursive: true, withFileTypes: true }) - const entry = entries.find((candidate) => candidate.isFile() && candidate.name === expected) - if (!entry) { - throw new Error(`Downloaded archive contained no ${expected}`) - } - return join(entry.parentPath, entry.name) -} - -function verifyRuntime(path) { - const result = runProcessSync({ program: path, args: ['--version'] }) - if (result.code !== 0 || result.stdout.trim() !== ORCAD_BUN_VERSION) { - throw new Error( - `Expected Bun ${ORCAD_BUN_VERSION} at ${path}, got ${result.stdout.trim() || result.stderr.trim()}` - ) - } -} - -async function materializeRuntime(target, outputPath) { - const asset = ORCAD_BUN_RELEASE_ASSETS[target] - if (!asset) { - throw new Error(`Unsupported Bun target: ${target}`) - } - const cached = join(cacheRoot, target, orcadBunRuntimeFilename(target)) - if (existsSync(cached) && sha256(cached) !== asset.executableSha256) { - rmSync(cached, { force: true }) - } - if (!existsSync(cached)) { - const temporary = mkdtempSync(join(tmpdir(), 'orca-bun-download-')) - try { - const zipPath = join(temporary, basename(asset.filename)) - await download(orcadBunReleaseUrl(asset), zipPath) - const actual = sha256(zipPath) - if (actual !== asset.sha256) { - throw new Error(`Bun checksum mismatch for ${asset.filename}: ${actual}`) - } - const extracted = join(temporary, 'extracted') - mkdirSync(extracted) - // Node 24.16 can leave extract-zip's stream promise unsettled with no active handles. - const command = getZipExtractorCommand(zipPath, extracted) - const result = runProcessSync({ - program: command.file, - args: command.args, - timeoutMs: 120_000 - }) - if (result.code !== 0) { - throw new Error( - `Bun archive extraction failed with exit ${result.code}: ${result.stderr || result.stdout}` - ) - } - mkdirSync(join(cacheRoot, target), { recursive: true }) - copyFileSync(findBunExecutable(extracted, target), cached) - if (!target.startsWith('win32-')) { - chmodSync(cached, 0o755) - } - } finally { - rmSync(temporary, { recursive: true, force: true }) - } - } - const executableHash = sha256(cached) - if (executableHash !== asset.executableSha256) { - throw new Error(`Bun executable checksum mismatch for ${target}: ${executableHash}`) - } - if (target === currentTarget()) { - verifyRuntime(cached) - } - mkdirSync(resolve(outputPath, '..'), { recursive: true }) - if (resolve(cached) !== resolve(outputPath)) { - copyFileSync(cached, outputPath) - } - if (!target.startsWith('win32-')) { - chmodSync(outputPath, 0o755) - } -} - -async function main() { - const target = argument('--target') ?? currentTarget() - const outputDir = argument('--out-dir') - const cachedRuntimePath = join(cacheRoot, target, orcadBunRuntimeFilename(target)) - const runtimePath = - process.argv.includes('--runtime-only') && outputDir - ? join(resolve(outputDir), orcadBunRuntimeFilename(target)) - : cachedRuntimePath - await materializeRuntime(target, runtimePath) - - if (process.argv.includes('--runtime-only')) { - process.stdout.write(`${runtimePath}\n`) - return - } - const result = runProcessSync({ - program: process.execPath, - args: [join(root, 'config/scripts/build-orcad.mjs')], - cwd: root, - env: { - ...process.env, - ORCAD_BUILD_TARGET: target, - ORCAD_BUILD_TARGET_IS_CURRENT: target === currentTarget() ? '1' : '0', - ORCAD_BUN_RUNTIME_PATH: runtimePath, - ...(outputDir ? { ORCAD_OUT_DIR: resolve(outputDir) } : {}) - }, - stdio: 'inherit', - timeoutMs: null - }) - if (result.code !== 0) { - process.exit(result.code ?? 1) - } -} - -if (process.argv[1]?.endsWith('build-orcad-bun.mjs')) { - await main() -} diff --git a/config/scripts/build-orcad-bun.test.mjs b/config/scripts/build-orcad-bun.test.mjs deleted file mode 100644 index bb3f06bc0794..000000000000 --- a/config/scripts/build-orcad-bun.test.mjs +++ /dev/null @@ -1,39 +0,0 @@ -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { afterEach, describe, expect, it } from 'vitest' -import { bunExecutableName, findBunExecutable } from './build-orcad-bun.mjs' - -const temporaryDirs = [] - -afterEach(() => { - for (const dir of temporaryDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }) - } -}) - -function archiveTree(filename) { - const root = mkdtempSync(join(tmpdir(), 'orcad-bun-archive-')) - temporaryDirs.push(root) - const nested = join(root, 'bun-release') - mkdirSync(nested) - writeFileSync(join(nested, filename), '') - return root -} - -describe('orcad Bun archive extraction', () => { - it('selects bun.exe for a Windows target on a non-Windows builder', () => { - const root = archiveTree('bun.exe') - expect(findBunExecutable(root, 'win32-x64')).toBe(join(root, 'bun-release', 'bun.exe')) - }) - - it('selects bun for a POSIX target', () => { - const root = archiveTree('bun') - expect(findBunExecutable(root, 'linux-x64-glibc')).toBe(join(root, 'bun-release', 'bun')) - }) - - it('derives executable names from the target rather than the builder host', () => { - expect(bunExecutableName('win32-arm64')).toBe('bun.exe') - expect(bunExecutableName('darwin-arm64')).toBe('bun') - }) -}) diff --git a/config/scripts/build-orcad-node.mjs b/config/scripts/build-orcad-node.mjs new file mode 100644 index 000000000000..32cc1b8068f6 --- /dev/null +++ b/config/scripts/build-orcad-node.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node +// Builds one orcad package for a server target and, for the build host's own target, places the +// pinned Node it references at `/../runtimes/node-/` (design D2). +import { chmodSync, copyFileSync, existsSync, mkdirSync, rmSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { + NODE_RUNTIME_ASSETS, + NODE_RUNTIME_PIN, + SERVER_TARGETS +} from '../../src/shared/node-runtime-pin.ts' +import { orcadNodeRuntimeRelativePath } from '../../src/shared/orcad-artifacts.ts' +import { ORCAD_PREBUILDS_DIR } from './build-orcad-prebuilds.mjs' +import { findSlotProblems, readManifest } from './orcad-prebuild-slot-contents.mjs' +import { ensurePinnedNodeExecutable } from './pinned-node-downloads.mjs' +import { runProcessSync } from './script-child-process.mjs' +import { currentTarget } from './server-build-target.mjs' + +const root = resolve(import.meta.dirname, '../..') + +function argument(name) { + const index = process.argv.indexOf(name) + return index === -1 ? null : process.argv[index + 1] +} + +/** Where a package in `outputDir` finds its runtime; mirrors the host store's layout. */ +export function packagedNodeRuntimePath(outputDir, target) { + return join( + outputDir, + ...orcadNodeRuntimeRelativePath(target, NODE_RUNTIME_ASSETS[target].executableSha256) + ) +} + +async function placeRuntime(outputDir, target) { + const source = await ensurePinnedNodeExecutable({ target }) + const destination = packagedNodeRuntimePath(outputDir, target) + mkdirSync(dirname(destination), { recursive: true }) + if (resolve(source) !== resolve(destination)) { + rmSync(destination, { force: true }) + copyFileSync(source, destination) + } + if (!target.startsWith('win32-')) { + chmodSync(destination, 0o755) + } + const version = runProcessSync({ program: destination, args: ['--version'] }) + if (version.code !== 0 || version.stdout.trim() !== `v${NODE_RUNTIME_PIN.version}`) { + throw new Error( + `Expected Node v${NODE_RUNTIME_PIN.version} at ${destination}, got ${version.stdout.trim() || version.stderr.trim()}` + ) + } + return destination +} + +/** A missing host slot is built here, so `pnpm build:orcad` needs no separate prebuild step. */ +function ensurePrebuildSlot(target, isCurrent) { + if (findSlotProblems(readManifest(ORCAD_PREBUILDS_DIR), ORCAD_PREBUILDS_DIR, [target]).length) { + if (!isCurrent) { + throw new Error( + `No verified node-pty prebuild for ${target} in ${ORCAD_PREBUILDS_DIR}; build it on a ${target} runner` + ) + } + const result = runProcessSync({ + program: process.execPath, + args: [join(root, 'config/scripts/build-orcad-prebuilds.mjs'), `--slot=${target}`], + cwd: root, + stdio: 'inherit', + timeoutMs: null + }) + if (result.code !== 0) { + throw new Error(`node-pty prebuild for ${target} failed with exit ${result.code}`) + } + } +} + +async function main() { + const target = argument('--target') ?? currentTarget() + if (!SERVER_TARGETS.includes(target)) { + throw new Error(`Unsupported server target: ${target}`) + } + const outputDir = resolve(argument('--out-dir') ?? join(root, 'out', 'orcad')) + const isCurrent = target === currentTarget() + ensurePrebuildSlot(target, isCurrent) + const runtimePath = isCurrent ? await placeRuntime(outputDir, target) : null + const result = runProcessSync({ + program: process.execPath, + args: [join(root, 'config/scripts/build-orcad.mjs')], + cwd: root, + env: { + ...process.env, + ORCAD_BUILD_TARGET: target, + ORCAD_OUT_DIR: outputDir, + ORCAD_PREBUILDS_DIR: ORCAD_PREBUILDS_DIR, + ...(runtimePath ? { ORCAD_NODE_RUNTIME_PATH: runtimePath } : {}) + }, + stdio: 'inherit', + timeoutMs: null + }) + if (result.code !== 0) { + process.exit(result.code ?? 1) + } + if (runtimePath && !existsSync(runtimePath)) { + throw new Error(`build-orcad removed the runtime at ${runtimePath}`) + } +} + +if (process.argv[1]?.endsWith('build-orcad-node.mjs')) { + await main() +} diff --git a/config/scripts/build-orcad-prebuilds.mjs b/config/scripts/build-orcad-prebuilds.mjs index 2d818d5d2558..73dbab5201ed 100644 --- a/config/scripts/build-orcad-prebuilds.mjs +++ b/config/scripts/build-orcad-prebuilds.mjs @@ -10,28 +10,53 @@ * #9902. So the matrix is compiled from patched sources here, and this script refuses to * run if the patch is not in the tree it is about to compile. * - * orcad pins its own Node runtime, so the ABI dimension is fixed and the matrix varies - * only platform/arch/libc: - * linux-x64-glibc, linux-arm64-glibc, linux-x64-musl, linux-arm64-musl, - * darwin-x64, darwin-arm64 + * Compiled against the pinned Node's hash-verified headers (src/shared/node-runtime-pin.ts) + * at N-API 8, so the matrix varies only platform/arch/libc (design D2): + * linux-{x64,arm64}-{glibc,musl}, darwin-{x64,arm64}, win32-{x64,arm64} * - * CI runs this once per slot, each inside the container that owns that libc/arch, and - * merges the resulting `out/orcad/prebuilds` trees. `--slot=` forces the label so + * CI runs this once per slot, each on the runner or container that owns that libc/arch, and + * merges the resulting `out/orcad-prebuilds` trees. `--slot=` forces the label so * the glibc/musl distinction is recorded from the container rather than detected. + * glibc slots are built on glibc 2.28 and gated there, not at the desktop's 2.31 (design D6); + * the opt-in `linux-x64-glibc217` compat slot (COMPAT_SLOTS) is built on glibc 2.17. * * Usage: * node config/scripts/build-orcad-prebuilds.mjs [--slot=linux-x64-musl] - * node config/scripts/build-orcad-prebuilds.mjs --require-slots # release gate + * node config/scripts/build-orcad-prebuilds.mjs --require-slots [slot,slot] # release gate + * node config/scripts/build-orcad-prebuilds.mjs [--slot=...] --smoke # load + spawn under the pinned Node + * node config/scripts/build-orcad-prebuilds.mjs --print-slot + * node config/scripts/build-orcad-prebuilds.mjs [--slot=...] --print-runtime # fetch + print the slot's pinned Node */ -import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' import process from 'node:process' -import { spawnSync } from 'node:child_process' +import { NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts' +import { + assertCompatSlotHost, + findPostBaselineNodeApiNames, + findSharedCxxRuntimeNeeds, + findSlotProblems, + highestGlibcNeed, + isCompatSlot, + mergeManifest, + prebuildCompileGypi, + readManifest, + sha256Of, + slotGlibcFloor, + slotSourceFiles, + SLOT_NAPI_VERSION +} from './orcad-prebuild-slot-contents.mjs' +import { ensurePinnedNodeExecutable, preparePinnedNodeDir } from './pinned-node-downloads.mjs' + +export { readManifest } const require = createRequire(import.meta.url) const ROOT = join(import.meta.dirname, '..', '..') -const PREBUILDS_DIR = join(ROOT, 'out', 'orcad', 'prebuilds') +// Why outside out/orcad: build:orcad wipes that directory and copies its target's slot from here. +export const ORCAD_PREBUILDS_DIR = join(ROOT, 'out', 'orcad-prebuilds') +const PREBUILDS_DIR = ORCAD_PREBUILDS_DIR +const WORK_DIR = join(ROOT, 'out', 'orcad-prebuild-work') /** Every slot a shipped matrix must fill. The single source of truth for the matrix. */ export const MATRIX_SLOTS = [ @@ -40,7 +65,9 @@ export const MATRIX_SLOTS = [ 'linux-x64-musl', 'linux-arm64-musl', 'darwin-x64', - 'darwin-arm64' + 'darwin-arm64', + 'win32-x64', + 'win32-arm64' ] /** @@ -71,6 +98,21 @@ export function slotName(argv = process.argv, platform = process.platform, arch return libc === 'none' ? `${platform}-${arch}` : `${platform}-${arch}-${libc}` } +/** `--require-slots` alone means the whole matrix; `--require-slots a,b` or `=a,b` names slots. */ +export function requestedSlots(argv = process.argv) { + const index = argv.findIndex( + (arg) => arg === '--require-slots' || arg.startsWith('--require-slots=') + ) + if (index === -1) { + return null + } + const inline = argv[index].slice('--require-slots='.length) + const next = argv[index + 1] + const list = argv[index].includes('=') ? inline : next && !next.startsWith('--') ? next : '' + const slots = list.split(',').filter(Boolean) + return slots.length > 0 ? slots : MATRIX_SLOTS +} + /** * The patch is what holds the Ubuntu 20.04 floor. Compiling without it produces a binary * that loads fine on the build host and dies on the target — the exact failure the matrix @@ -98,120 +140,250 @@ export function assertNodePtyPatchApplied(nodePtyDir) { } } -export function readManifest(prebuildsDir) { - try { - return JSON.parse(readFileSync(join(prebuildsDir, 'manifest.json'), 'utf8')) - } catch { - return null +const GLIBC_DT_NEEDED_LDFLAG = "'-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0,--as-needed'" + +/** + * musl has no libutil.so.1 (openpty/forkpty live in libc and libutil is an empty static stub), + * so the patch's glibc DT_NEEDED ldflag cannot link there. + */ +export function bindingGypForLibc(bindingGyp, libc) { + if (libc !== 'musl') { + return bindingGyp + } + if (!bindingGyp.includes(GLIBC_DT_NEEDED_LDFLAG)) { + throw new Error( + '[orcad-prebuilds] binding.gyp no longer carries the glibc ldflag this build strips on musl' + ) } + return bindingGyp.replace(GLIBC_DT_NEEDED_LDFLAG, '') } +const GLIBC_SYMVER_GUARD = + '#if defined(__linux__)\n# if defined(__x86_64__)\n# define ORCA_GLIBC_COMPAT_VERSION' + /** - * Why merge rather than overwrite: CI builds one slot per container and merges the trees. - * A manifest that records only the last slot would erase every other container's record, - * and `--require-slots` would then reject a complete matrix. + * The patch's `.symver` pins bind openpty/forkpty/pthread_sigmask to `@GLIBC_*` versions, which + * musl's unversioned libc cannot satisfy at link time; musl never defines __GLIBC__. */ -export function mergeManifest(existing, next) { - const slots = new Set([...(existing?.slots ?? []), next.slot]) - return { - module: 'node-pty', - version: next.version, - nodeAbi: next.nodeAbi, - slots: [...slots].sort() +export function ptySourceForLibc(ptySource, libc) { + if (libc !== 'musl') { + return ptySource + } + if (!ptySource.includes(GLIBC_SYMVER_GUARD)) { + throw new Error( + '[orcad-prebuilds] pty.cc no longer carries the glibc .symver guard this build scopes on musl' + ) } + return ptySource.replace( + GLIBC_SYMVER_GUARD, + GLIBC_SYMVER_GUARD.replace('defined(__linux__)', 'defined(__linux__) && defined(__GLIBC__)') + ) } function nodePtyDir() { return dirname(require.resolve('node-pty/package.json')) } -function compileNodePty(dir) { - const built = join(dir, 'build', 'Release', 'pty.node') - if (existsSync(built)) { - console.log(`[orcad-prebuilds] reusing existing build at ${built}`) - return built - } - console.log('[orcad-prebuilds] compiling node-pty from patched source ...') - const result = spawnSync( - process.platform === 'win32' ? 'npx.cmd' : 'npx', - ['node-gyp', 'rebuild'], - { - cwd: dir, - stdio: 'inherit', - env: process.env, - windowsHide: true - } +/** + * Compile in a scratch copy, never in node_modules/node-pty: that tree holds the Electron + * build the desktop loads, and a Node-headers rebuild there would break the app. + */ +async function compileNodePty(sourceDir, slot) { + const workDir = join(WORK_DIR, slot) + const stagedDir = join(workDir, 'node-pty') + rmSync(workDir, { recursive: true, force: true }) + mkdirSync(stagedDir, { recursive: true }) + for (const entry of ['package.json', 'src']) { + cpSync(join(sourceDir, entry), join(stagedDir, entry), { recursive: true }) + } + const libc = detectLibc() + writeFileSync( + join(stagedDir, 'binding.gyp'), + bindingGypForLibc(readFileSync(join(sourceDir, 'binding.gyp'), 'utf8'), libc) + ) + const ptySourcePath = join(stagedDir, 'src', 'unix', 'pty.cc') + writeFileSync(ptySourcePath, ptySourceForLibc(readFileSync(ptySourcePath, 'utf8'), libc)) + const addonApiDir = dirname( + require.resolve('node-addon-api/package.json', { paths: [sourceDir] }) + ) + cpSync(addonApiDir, join(stagedDir, 'node_modules', 'node-addon-api'), { + recursive: true, + dereference: true + }) + if (process.platform === 'win32') { + require('./node-pty-job-ownership.cjs').assertNodePtySourceDeniesMsysBreakaway({ + nodePtyDir: stagedDir + }) + } + const compileGypi = join(workDir, 'prebuild-compile.gypi') + writeFileSync(compileGypi, prebuildCompileGypi({ staticCxxRuntime: isCompatSlot(slot) })) + const nodeDir = await preparePinnedNodeDir({ target: slot, workDir: join(workDir, 'nodedir') }) + + console.log( + `[orcad-prebuilds] compiling patched node-pty for ${slot} against Node ${NODE_RUNTIME_PIN.version} headers, N-API ${SLOT_NAPI_VERSION} ...` ) - if (result.status !== 0) { - throw new Error(`[orcad-prebuilds] node-gyp rebuild failed (status ${result.status})`) + const { runProcessSync } = await import('./script-child-process.mjs') + const result = runProcessSync({ + program: process.execPath, + args: [ + join(ROOT, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js'), + 'rebuild', + `--nodedir=${nodeDir}`, + '--', + '-I', + compileGypi + ], + cwd: stagedDir, + stdio: 'inherit', + timeoutMs: null + }) + if (result.code !== 0) { + throw new Error(`[orcad-prebuilds] node-gyp rebuild failed (status ${result.code})`) } - if (!existsSync(built)) { - throw new Error(`[orcad-prebuilds] node-gyp succeeded but ${built} is missing`) + const buildDir = join(stagedDir, 'build', 'Release') + if (process.platform === 'win32') { + require('./node-pty-job-ownership.cjs').assertRebuiltConptyDeniesMsysBreakaway({ + nodePtyDir: stagedDir, + rebuildArch: process.arch, + crossHost: false + }) } - return built + return buildDir } -function requireSlots() { - const manifest = readManifest(PREBUILDS_DIR) - const have = new Set(manifest?.slots ?? []) - const missing = MATRIX_SLOTS.filter((slot) => !have.has(slot)) - if (missing.length > 0) { +function requireSlots(slots) { + const problems = findSlotProblems(readManifest(PREBUILDS_DIR), PREBUILDS_DIR, slots) + if (problems.length > 0) { console.error( - `[orcad-prebuilds] matrix incomplete — missing ${missing.join(', ')}. ` + + `[orcad-prebuilds] matrix incomplete:\n${problems.map((p) => ` - ${p}`).join('\n')}\n` + 'Hosts on those slots fall back to a source build and need a C/C++ toolchain.' ) process.exitCode = 1 return } - console.log(`[orcad-prebuilds] matrix complete — ${MATRIX_SLOTS.length} slots`) + console.log(`[orcad-prebuilds] verified ${slots.length} slot(s): ${slots.join(', ')}`) } -function build() { - const dir = nodePtyDir() - assertNodePtyPatchApplied(dir) - const slot = slotName() - const slotDir = join(PREBUILDS_DIR, slot) - mkdirSync(slotDir, { recursive: true }) - - const builtBinary = compileNodePty(dir) - copyFileSync(builtBinary, join(slotDir, 'pty.node')) - console.log(`[orcad-prebuilds] stored ${slot}/pty.node`) - - // Why spawn-helper ships too: on macOS node-pty posix_spawns build/Release/spawn-helper, - // so a slot without it installs cleanly and then fails ENOENT the first time a user - // opens a terminal. binding.gyp builds the helper only under OS=="mac"; every other - // platform forks directly, so demanding one there fails a healthy Linux slot build. - if (process.platform === 'darwin') { - const helperSource = join(dirname(builtBinary), 'spawn-helper') - if (!existsSync(helperSource)) { - throw new Error(`[orcad-prebuilds] spawn-helper missing at ${helperSource}`) +/** + * Linux-only gates: architecture always; the slot's glibc/libstdc++ floor and the recorded + * glibc need only for glibc slots, since a musl slot never meets glibc's libraries. + */ +function linuxSlotRecord(slot, slotDir, libc) { + const floor = require('./verify-linux-glibc-floor.cjs') + if (libc !== 'glibc') { + for (const binary of floor.collectNativeBinaries(slotDir)) { + const violation = floor.findArchViolation(binary, process.arch, slotDir) + if (violation) { + throw new Error( + `[orcad-prebuilds] ${binary} is ${violation.actual}, expected ${process.arch}` + ) + } } - copyFileSync(helperSource, join(slotDir, 'spawn-helper')) - console.log(`[orcad-prebuilds] stored ${slot}/spawn-helper`) + return { glibc: null } } + floor.verifyLinuxGlibcFloor(slotDir, { + targetArch: process.arch, + glibcFloor: slotGlibcFloor(slot) + }) + const objdump = process.env.OBJDUMP || 'objdump' + const binaries = floor.collectNativeBinaries(slotDir) + if (isCompatSlot(slot)) { + for (const binary of binaries) { + const shared = findSharedCxxRuntimeNeeds( + floor.readDynamicInfo(binary, objdump).neededLibraries + ) + if (shared.length > 0) { + throw new Error( + `[orcad-prebuilds] ${binary} needs ${shared.join(', ')}; the ${slot} slot must link its C++ runtime statically` + ) + } + } + } + return { + glibc: highestGlibcNeed(binaries, (path) => floor.readDynamicInfo(path, objdump).versionNeeds) + } +} + +async function build() { + const sourceDir = nodePtyDir() + assertNodePtyPatchApplied(sourceDir) + const slot = slotName() + assertCompatSlotHost(slot, { platform: process.platform, arch: process.arch, libc: detectLibc() }) + const slotDir = join(PREBUILDS_DIR, slot) + const buildDir = await compileNodePty(sourceDir, slot) - // The static floor gate, applied to the artifact we are about to ship rather than only - // to the packaged desktop app. objdump is Linux-only, which is where the floor lives. - if (process.platform === 'linux') { - const { verifyLinuxGlibcFloor } = require('./verify-linux-glibc-floor.cjs') - verifyLinuxGlibcFloor(slotDir) + rmSync(slotDir, { recursive: true, force: true }) + const files = {} + for (const [relative, source] of slotSourceFiles({ + platform: process.platform, + arch: process.arch, + buildDir, + nodePtyDir: sourceDir + })) { + if (!existsSync(source)) { + throw new Error(`[orcad-prebuilds] ${slot} needs ${relative}, but ${source} is missing`) + } + const destination = join(slotDir, ...relative.split('/')) + mkdirSync(dirname(destination), { recursive: true }) + cpSync(source, destination) + files[relative] = sha256Of(destination) + if (relative.endsWith('.node')) { + const newer = findPostBaselineNodeApiNames(readFileSync(destination)) + if (newer.length > 0) { + throw new Error( + `[orcad-prebuilds] ${slot}/${relative} imports ${newer.join(', ')}, above N-API ${SLOT_NAPI_VERSION}; host Node 18 could not load it` + ) + } + } + console.log(`[orcad-prebuilds] stored ${slot}/${relative}`) } + const libc = detectLibc() + const { glibc } = + process.platform === 'linux' ? linuxSlotRecord(slot, slotDir, libc) : { glibc: null } const manifest = mergeManifest(readManifest(PREBUILDS_DIR), { slot, version: require('node-pty/package.json').version, - nodeAbi: process.versions.modules + napi: SLOT_NAPI_VERSION, + nodeHeaders: NODE_RUNTIME_PIN.version, + entry: { + platform: process.platform, + arch: process.arch, + libc, + glibc, + napi: SLOT_NAPI_VERSION, + files + } }) writeFileSync(join(PREBUILDS_DIR, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`) console.log( - `[orcad-prebuilds] manifest: node-pty ${manifest.version}, ABI ${manifest.nodeAbi}, slots ${manifest.slots.join(', ')}` + `[orcad-prebuilds] manifest: node-pty ${manifest.version}, N-API ${manifest.napi}, ` + + `slots ${Object.keys(manifest.slots).join(', ')}` ) } -if (process.argv[1] && process.argv[1].endsWith('build-orcad-prebuilds.mjs')) { - if (process.argv.includes('--require-slots')) { - requireSlots() - } else { - build() +async function main() { + if (process.argv.includes('--print-slot')) { + console.log(slotName()) + return + } + if (process.argv.includes('--print-runtime')) { + console.log(await ensurePinnedNodeExecutable({ target: slotName() })) + return } + const slots = requestedSlots() + if (slots) { + requireSlots(slots) + return + } + if (process.argv.includes('--smoke')) { + const { runOrcadPrebuildSmoke } = await import('./orcad-prebuild-smoke.mjs') + await runOrcadPrebuildSmoke({ slot: slotName(), prebuildsDir: PREBUILDS_DIR }) + return + } + await build() +} + +if (process.argv[1] && process.argv[1].endsWith('build-orcad-prebuilds.mjs')) { + await main() } diff --git a/config/scripts/build-orcad-prebuilds.test.mjs b/config/scripts/build-orcad-prebuilds.test.mjs index c91c7e6cc9da..2676c0eb0163 100644 --- a/config/scripts/build-orcad-prebuilds.test.mjs +++ b/config/scripts/build-orcad-prebuilds.test.mjs @@ -1,13 +1,16 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { assertNodePtyPatchApplied, + bindingGypForLibc, + ptySourceForLibc, detectLibc, MATRIX_SLOTS, - mergeManifest, readManifest, + requestedSlots, slotName } from './build-orcad-prebuilds.mjs' @@ -67,7 +70,21 @@ describe('slot naming', () => { 'linux-arm64-glibc', 'linux-arm64-musl', 'linux-x64-glibc', - 'linux-x64-musl' + 'linux-x64-musl', + 'win32-arm64', + 'win32-x64' + ]) + }) + + it('requires the whole matrix by default and only the named slots otherwise', () => { + expect(requestedSlots(['node', 'x'])).toBeNull() + expect(requestedSlots(['node', 'x', '--require-slots'])).toEqual(MATRIX_SLOTS) + expect(requestedSlots(['node', 'x', '--require-slots', 'darwin-arm64'])).toEqual([ + 'darwin-arm64' + ]) + expect(requestedSlots(['node', 'x', '--require-slots=win32-x64,win32-arm64'])).toEqual([ + 'win32-x64', + 'win32-arm64' ]) }) @@ -91,26 +108,59 @@ describe('slot naming', () => { }) }) -describe('mergeManifest', () => { - it('accumulates slots across the per-container CI runs that build them', () => { - // Overwriting would erase every other container's record, and the release gate would - // then reject a matrix that is actually complete. - const first = mergeManifest(null, { slot: 'linux-x64-glibc', version: '1.1.0', nodeAbi: '127' }) - const second = mergeManifest(first, { - slot: 'linux-arm64-musl', - version: '1.1.0', - nodeAbi: '127' - }) - - expect(second.slots).toEqual(['linux-arm64-musl', 'linux-x64-glibc']) - expect(second).toMatchObject({ module: 'node-pty', version: '1.1.0', nodeAbi: '127' }) - }) - - it('does not duplicate a slot rebuilt twice', () => { - const once = mergeManifest(null, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' }) - expect( - mergeManifest(once, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' }).slots - ).toEqual(['darwin-arm64']) +describe('bindingGypForLibc', () => { + const gyp = + "'ldflags': [\n '-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0,--as-needed'\n]" + + it('keeps the glibc DT_NEEDED ldflag everywhere but musl', () => { + expect(bindingGypForLibc(gyp, 'glibc')).toBe(gyp) + expect(bindingGypForLibc(gyp, 'none')).toBe(gyp) + }) + + it('drops it on musl, which has no libutil.so.1 to link', () => { + expect(bindingGypForLibc(gyp, 'musl')).not.toContain('libutil.so.1') + }) + + it('matches the binding.gyp the installed patch produces', () => { + const require = createRequire(import.meta.url) + const installed = readFileSync( + join(dirname(require.resolve('node-pty/package.json')), 'binding.gyp'), + 'utf8' + ) + expect(bindingGypForLibc(installed, 'musl')).not.toContain('-l:libutil.so.1') + }) + + it('fails loudly if the patch stops carrying the flag it strips', () => { + expect(() => bindingGypForLibc("'ldflags': []", 'musl')).toThrow(/no longer carries/) + }) +}) + +describe('ptySourceForLibc', () => { + const source = + '#if defined(__linux__)\n# if defined(__x86_64__)\n# define ORCA_GLIBC_COMPAT_VERSION "GLIBC_2.2.5"\n' + + it('keeps the glibc .symver pins everywhere but musl', () => { + expect(ptySourceForLibc(source, 'glibc')).toBe(source) + expect(ptySourceForLibc(source, 'none')).toBe(source) + }) + + it('scopes them to glibc on musl, whose libc has no GLIBC_ versions to bind', () => { + expect(ptySourceForLibc(source, 'musl')).toMatch( + /^#if defined\(__linux__\) && defined\(__GLIBC__\)\n/ + ) + }) + + it('matches the pty.cc the installed patch produces', () => { + const require = createRequire(import.meta.url) + const installed = readFileSync( + join(dirname(require.resolve('node-pty/package.json')), 'src', 'unix', 'pty.cc'), + 'utf8' + ) + expect(ptySourceForLibc(installed, 'musl')).toContain('defined(__GLIBC__)') + }) + + it('fails loudly if the patch stops carrying the guard it scopes', () => { + expect(() => ptySourceForLibc('int main() {}', 'musl')).toThrow(/no longer carries/) }) }) diff --git a/config/scripts/build-orcad-template.mjs b/config/scripts/build-orcad-template.mjs index a69a6ee8485e..77d0aa08a15f 100644 --- a/config/scripts/build-orcad-template.mjs +++ b/config/scripts/build-orcad-template.mjs @@ -12,16 +12,14 @@ import { } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { - ORCAD_BUILD_TARGET_FILENAME, ORCAD_TEMPLATE_MANIFEST_FILENAME, ORCAD_TEMPLATE_TARGETS_DIR, - ORCAD_RIPGREP_ARTIFACTS, - orcadTemplateCommonFilenames + orcadTemplateCommonFilenames, + orcadTemplateTargetFilenames } from '../../src/shared/orcad-artifacts.ts' import { orcadAgentBrowserNativeName } from '../../src/shared/orcad-agent-browser-name.ts' -import { ORCAD_TEMPLATE_TARGETS } from '../../src/shared/orcad-bun-runtime.ts' +import { ORCAD_TEMPLATE_TARGETS } from '../../src/shared/node-runtime-pin.ts' import { runProcessSync } from './script-child-process.mjs' -import { materializeWatcherPackage } from './orcad-watcher-package.mjs' import { verifyPackagedOrcadTemplate } from './verify-packaged-orcad-template.cjs' const root = resolve(import.meta.dirname, '../..') @@ -29,6 +27,10 @@ const outputDir = join(root, 'out', 'orcad-template') const buildDir = join(root, 'out', '.orcad-template-build') const commonArtifacts = orcadTemplateCommonFilenames() +function isExecutable(filename) { + return /(?:^|\/)(?:rg|spawn-helper)$/.test(filename) +} + function copy(source, destination, executable = false) { mkdirSync(dirname(destination), { recursive: true }) copyFileSync(source, destination) @@ -41,78 +43,81 @@ function sha256(path) { return createHash('sha256').update(readFileSync(path)).digest('hex') } -function targetPlatform(target) { - return target.split('-')[0] -} - -function targetArch(target) { - return target.split('-')[1] -} - -function buildCommonArtifacts() { - rmSync(buildDir, { recursive: true, force: true }) +/** One full package per target; each needs that target's node-pty slot in out/orcad-prebuilds. */ +function buildTargetPackage(target) { + const packageDir = join(buildDir, target, 'orcad') const result = runProcessSync({ program: process.execPath, - args: [join(root, 'config/scripts/build-orcad-bun.mjs'), '--out-dir', buildDir], + args: [ + join(root, 'config/scripts/build-orcad-node.mjs'), + '--target', + target, + '--out-dir', + packageDir + ], cwd: root, stdio: 'inherit', timeoutMs: null }) if (result.code !== 0) { - throw new Error(`Common orcad artifact build failed with exit ${result.code ?? 'unknown'}`) + throw new Error(`orcad ${target} package build failed with exit ${result.code ?? 'unknown'}`) } + return packageDir } -async function stageTarget(target) { +function stageTarget(target, packageDir) { const destination = join(outputDir, ORCAD_TEMPLATE_TARGETS_DIR, target) - const targetIdentity = join(destination, ORCAD_BUILD_TARGET_FILENAME) - mkdirSync(destination, { recursive: true }) - writeFileSync(targetIdentity, `${target}\n`) - const watcherSource = await materializeWatcherPackage(target) - const watcherDestination = join(destination, 'watcher.node') - copy(watcherSource, watcherDestination) - + const files = {} + for (const filename of orcadTemplateTargetFilenames(target)) { + const staged = join(destination, ...filename.split('/')) + copy(join(packageDir, ...filename.split('/')), staged, isExecutable(filename)) + files[filename] = sha256(staged) + } const browserName = orcadAgentBrowserNativeName( - targetPlatform(target), - targetArch(target), + target.split('-')[0], + target.split('-')[1], target.endsWith('-musl') ? 'musl' : 'glibc' ) - const browserSource = join(root, 'node_modules', 'agent-browser', 'bin', browserName) - const browserDestination = join(destination, browserName) - if (existsSync(browserSource)) { - copy(browserSource, browserDestination, true) - } - return { - targetSha256: sha256(targetIdentity), - watcherSha256: sha256(watcherDestination), - ...(existsSync(browserDestination) - ? { browserName, browserSha256: sha256(browserDestination) } - : {}) + const browserSource = join(packageDir, browserName) + if (!existsSync(browserSource)) { + return { files } } + const browserDestination = join(destination, browserName) + copy(browserSource, browserDestination, true) + return { files, browserName, browserSha256: sha256(browserDestination) } +} + +function sameBytes(left, right) { + return sha256(left) === sha256(right) } async function main() { - buildCommonArtifacts() + rmSync(buildDir, { recursive: true, force: true }) + const packages = Object.fromEntries( + ORCAD_TEMPLATE_TARGETS.map((target) => [target, buildTargetPackage(target)]) + ) rmSync(outputDir, { recursive: true, force: true }) mkdirSync(outputDir, { recursive: true }) + const [firstTarget] = ORCAD_TEMPLATE_TARGETS for (const filename of commonArtifacts) { - copy( - join(buildDir, filename), - join(outputDir, filename), - ORCAD_RIPGREP_ARTIFACTS.some((artifact) => artifact === filename && artifact.endsWith('/rg')) - ) + const source = join(packages[firstTarget], ...filename.split('/')) + // Why check every target: the template keeps one copy, so a per-target difference would ship wrong bytes. + for (const target of ORCAD_TEMPLATE_TARGETS) { + if (!sameBytes(source, join(packages[target], ...filename.split('/')))) { + throw new Error(`${filename} differs between ${firstTarget} and ${target} packages`) + } + } + copy(source, join(outputDir, ...filename.split('/')), isExecutable(filename)) } const targets = Object.fromEntries( - await Promise.all( - ORCAD_TEMPLATE_TARGETS.map(async (target) => [target, await stageTarget(target)]) - ) + ORCAD_TEMPLATE_TARGETS.map((target) => [target, stageTarget(target, packages[target])]) ) const commonSha256 = Object.fromEntries( commonArtifacts.map((filename) => [filename, sha256(join(outputDir, filename))]) ) writeFileSync( join(outputDir, ORCAD_TEMPLATE_MANIFEST_FILENAME), - `${JSON.stringify({ schemaVersion: 2, commonSha256, targets }, null, 2)}\n` + `${JSON.stringify({ schemaVersion: 3, commonSha256, targets }, null, 2)}\n` ) verifyPackagedOrcadTemplate(join(root, 'out')) rmSync(buildDir, { recursive: true, force: true }) diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index b015f52ea2e6..cbfb85e47339 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// Ship Bun with orcad; keep module loading compatible with legacy Node launchers. +// Package orcad for the pinned Node; keep module loading compatible with legacy Node launchers. import { fork, spawnSync } from 'node:child_process' import { build } from 'esbuild' import { @@ -26,16 +26,20 @@ import { smokeProfileStateWorkers } from './profile-state-worker-smoke.mjs' import { materializeWatcherPackage } from './orcad-watcher-package.mjs' import { stageOrcadWindowsProcessTree } from './orcad-windows-process-tree.mjs' import { - ORCAD_BUILD_TARGET_FILENAME, ORCAD_EMOJI_SHORTCODE_DATASET, - orcadBunRuntimeFilename, + ORCAD_NODE_PTY_DIR, + ORCAD_NODE_PTY_JS_ARTIFACTS, + ORCAD_NODE_RUNTIME_MARKER_FILENAME, ORCAD_PARCEL_WATCHER_ENTRY, ORCAD_PARCEL_WATCHER_NATIVE, + ORCAD_SERVER_TARGET_FILENAME, ORCAD_VERSION_FILENAME, - ORCAD_RIPGREP_ARTIFACTS + orcadNodePtySlotFiles, + orcadRipgrepArtifact } from '../../src/shared/orcad-artifacts.ts' import { computeOrcadFullVersion } from './orcad-artifact-version.mjs' -import { ORCAD_BUN_VERSION } from '../../src/shared/orcad-bun-runtime.ts' +import { NODE_RUNTIME_ASSETS, NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts' +import { findSlotProblems, readManifest } from './orcad-prebuild-slot-contents.mjs' import { orcadAgentBrowserNativeName } from '../../src/shared/orcad-agent-browser-name.ts' const ROOT = join(import.meta.dirname, '..', '..') @@ -52,8 +56,6 @@ const WATCHER_OUT_FILE = join(OUT_DIR, 'parcel-watcher-process-entry.js') // orcad restart would SIGKILL every running terminal. const DAEMON_ENTRY = join(ROOT, ORCAD_CHILD_ENTRY_POINTS.daemon) const DAEMON_OUT_FILE = join(OUT_DIR, 'daemon-entry.js') -const PTY_GATE_ENTRY = join(ROOT, ORCAD_CHILD_ENTRY_POINTS.ptyGate) -const PTY_GATE_OUT_FILE = join(OUT_DIR, 'windows-bun-pty-gate-entry.js') const OUT_FILE = join(OUT_DIR, 'orcad.js') const BUILD_TARGET = process.env.ORCAD_BUILD_TARGET if (!BUILD_TARGET) { @@ -61,7 +63,16 @@ if (!BUILD_TARGET) { } const [targetPlatform, targetArch] = BUILD_TARGET.split('-') const targetIsWindows = targetPlatform === 'win32' -const targetIsCurrent = process.env.ORCAD_BUILD_TARGET_IS_CURRENT === '1' +const runtimeAsset = NODE_RUNTIME_ASSETS[BUILD_TARGET] +if (!runtimeAsset) { + throw new Error(`Unsupported ORCAD_BUILD_TARGET: ${BUILD_TARGET}`) +} +// Set only when the build host can run this target's runtime. +const nodeRuntimePath = process.env.ORCAD_NODE_RUNTIME_PATH || null +const PREBUILDS_DIR = process.env.ORCAD_PREBUILDS_DIR +if (!PREBUILDS_DIR) { + throw new Error('ORCAD_PREBUILDS_DIR is required; run `pnpm build:orcad`') +} const AGENT_BROWSER_NAME = orcadAgentBrowserNativeName( targetPlatform, targetArch, @@ -99,26 +110,43 @@ async function stageParcelWatcher(target) { copyFileSync(nativeSource, join(OUT_DIR, ORCAD_PARCEL_WATCHER_NATIVE)) } -rmSync(OUT_DIR, { recursive: true, force: true }) -mkdirSync(OUT_DIR, { recursive: true }) -const bunRuntimeSource = process.env.ORCAD_BUN_RUNTIME_PATH -if (!bunRuntimeSource) { - throw new Error('ORCAD_BUN_RUNTIME_PATH is required; run `pnpm build:orcad`') -} -if (targetIsCurrent) { - const version = spawnSync(bunRuntimeSource, ['--version'], { encoding: 'utf8' }) - if (version.status !== 0 || version.stdout.trim() !== ORCAD_BUN_VERSION) { +/** node-pty's JS plus only this target's prebuild slot, laid out the way node-pty's loader looks. */ +function stageNodePty(target) { + const problems = findSlotProblems(readManifest(PREBUILDS_DIR), PREBUILDS_DIR, [target]) + if (problems.length > 0) { + throw new Error(`[build-orcad] node-pty prebuild for ${target}: ${problems.join('; ')}`) + } + const slotFiles = Object.keys(readManifest(PREBUILDS_DIR).slots[target].files).sort() + const expected = orcadNodePtySlotFiles(target).sort() + if (slotFiles.join('\0') !== expected.join('\0')) { throw new Error( - `ORCAD_BUN_RUNTIME_PATH must be Bun ${ORCAD_BUN_VERSION}; got ${version.stdout.trim() || version.stderr.trim()}` + `[build-orcad] ${target} prebuild ships ${slotFiles.join(', ')}; orcad-artifacts.ts expects ${expected.join(', ')}` ) } + const sourceDir = dirname(createRequire(import.meta.url).resolve('node-pty/package.json')) + for (const artifact of ORCAD_NODE_PTY_JS_ARTIFACTS) { + const destination = join(OUT_DIR, artifact) + mkdirSync(dirname(destination), { recursive: true }) + copyFileSync(join(sourceDir, artifact.slice(ORCAD_NODE_PTY_DIR.length + 1)), destination) + } + for (const file of expected) { + const destination = join(OUT_DIR, ORCAD_NODE_PTY_DIR, 'build', 'Release', ...file.split('/')) + mkdirSync(dirname(destination), { recursive: true }) + copyFileSync(join(PREBUILDS_DIR, target, ...file.split('/')), destination) + if (file === 'spawn-helper') { + chmodSync(destination, 0o755) + } + } } -const bunRuntimeOutput = join(OUT_DIR, orcadBunRuntimeFilename(BUILD_TARGET)) -copyFileSync(bunRuntimeSource, bunRuntimeOutput) -writeFileSync(join(OUT_DIR, ORCAD_BUILD_TARGET_FILENAME), `${BUILD_TARGET}\n`) -if (!targetIsWindows) { - chmodSync(bunRuntimeOutput, 0o755) -} + +rmSync(OUT_DIR, { recursive: true, force: true }) +mkdirSync(OUT_DIR, { recursive: true }) +writeFileSync(join(OUT_DIR, ORCAD_SERVER_TARGET_FILENAME), `${BUILD_TARGET}\n`) +writeFileSync( + join(OUT_DIR, ORCAD_NODE_RUNTIME_MARKER_FILENAME), + `${runtimeAsset.executableSha256}\n` +) +stageNodePty(BUILD_TARGET) await stageParcelWatcher(BUILD_TARGET) stageOrcadWindowsProcessTree(ROOT, OUT_DIR, BUILD_TARGET) const emojiDatasetOutput = join(OUT_DIR, ORCAD_EMOJI_SHORTCODE_DATASET) @@ -133,9 +161,8 @@ if (existsSync(AGENT_BROWSER_SOURCE)) { chmodSync(AGENT_BROWSER_OUTPUT, 0o755) } } -// Why every platform: an SSH deployment can target a different host than the build machine. -for (const artifact of ORCAD_RIPGREP_ARTIFACTS) { - const [, ripgrepPlatform, ripgrepName] = artifact.split('/') +{ + const [, ripgrepPlatform, ripgrepName] = orcadRipgrepArtifact(BUILD_TARGET).split('/') const outputDir = join(OUT_DIR, 'ripgrep', ripgrepPlatform) mkdirSync(outputDir, { recursive: true }) const outputPath = join(outputDir, ripgrepName) @@ -143,7 +170,7 @@ for (const artifact of ORCAD_RIPGREP_ARTIFACTS) { join(ROOT, 'node_modules', '@vscode', 'ripgrep-universal', 'bin', ripgrepPlatform, ripgrepName), outputPath ) - if (!ripgrepPlatform.startsWith('win32-')) { + if (!targetIsWindows) { chmodSync(outputPath, 0o755) } } @@ -177,7 +204,6 @@ function buildForkedChild(entryPoint, outfile) { const childResults = await Promise.all([ buildForkedChild(WATCHER_ENTRY, WATCHER_OUT_FILE), buildForkedChild(DAEMON_ENTRY, DAEMON_OUT_FILE), - buildForkedChild(PTY_GATE_ENTRY, PTY_GATE_OUT_FILE), ...['writer', 'backup'].map((role) => buildForkedChild( join(ROOT, ORCAD_CHILD_ENTRY_POINTS[role]), @@ -294,7 +320,7 @@ if (graphErrors.length > 0) { ) process.exitCode = 1 } - const watcherFailure = targetIsCurrent ? await smokeLoadWatcherChild(bunRuntimeOutput) : null + const watcherFailure = nodeRuntimePath ? await smokeLoadWatcherChild(nodeRuntimePath) : null if (watcherFailure) { console.error( `[build-orcad] the watcher child failed under the bundled runtime.\n${watcherFailure}` @@ -305,8 +331,8 @@ if (graphErrors.length > 0) { try { await smokeProfileStateWorkers(OUT_DIR) - if (targetIsCurrent) { - await smokeProfileStateWorkers(OUT_DIR, { runtimePath: bunRuntimeOutput }) + if (nodeRuntimePath) { + await smokeProfileStateWorkers(OUT_DIR, { runtimePath: nodeRuntimePath }) } } catch (error) { console.error('[build-orcad] profile state worker check failed:', error) @@ -324,7 +350,7 @@ if (process.exitCode !== 1) { }) writeFileSync(join(OUT_DIR, ORCAD_VERSION_FILENAME), fullVersion) console.log( - `[build-orcad] ok — ${fullVersion}, ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports, Bun ${ORCAD_BUN_VERSION} included.` + `[build-orcad] ok — ${fullVersion}, ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports, Node ${NODE_RUNTIME_PIN.version} referenced.` ) } diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index a122c661df13..4bc46cdc9f68 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -173,7 +173,7 @@ async function buildRelayBundles(outDir) { target: 'node18', format: 'cjs', outfile: join(outDir, RELAY_OPENCODE_SQLITE_READER_FILENAME), - external: ['electron', 'bun:sqlite'], + external: ['electron'], sourcemap: false, minify: true, define: { 'process.env.NODE_ENV': '"production"' } diff --git a/config/scripts/bun-profile-test-paths.mjs b/config/scripts/bun-profile-test-paths.mjs deleted file mode 100644 index 23c8f423acd2..000000000000 --- a/config/scripts/bun-profile-test-paths.mjs +++ /dev/null @@ -1,24 +0,0 @@ -export function bunProfileTestPaths({ artifact = false } = {}) { - return [ - 'src/main/persistence/profile-state', - 'src/main/persistence/loading-store/profile-state', - 'src/main/sqlite', - 'src/main/orcad/orcad-entry.test.ts', - 'src/main/orcad/orcad-push-startup.test.ts', - ...(artifact - ? [ - 'src/main/daemon/pty-subprocess/bun-pty-process.integration.test.ts', - 'src/main/daemon/pty-subprocess/bun-pty-job-control.integration.test.ts', - 'src/main/daemon/pty-subprocess/bun-pty-process-suspension.test.ts', - 'src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts', - 'src/main/daemon/pty-subprocess/spawn-file-foreground-rejected-agents.test.ts', - 'tests/e2e/daemon-running-work-probe.unit.test.ts', - 'src/main/daemon/pty-subprocess/windows-bun-pty-gate.integration.test.ts', - 'src/main/providers/local-pty-bun-artifact.integration.test.ts', - 'src/main/providers/agent-foreground-process-git-bash.win32.test.ts', - 'src/main/orcad/orcad-bun-launcher.integration.test.ts', - 'config/scripts/zip-extractor-command.test.mjs' - ] - : []) - ] -} diff --git a/config/scripts/check-daemon-protocol-crossing.mjs b/config/scripts/check-daemon-protocol-crossing.mjs new file mode 100644 index 000000000000..f46b875cde74 --- /dev/null +++ b/config/scripts/check-daemon-protocol-crossing.mjs @@ -0,0 +1,113 @@ +// R1 gate: the working tree must attach the newest release's terminal daemon, or an +// upgrade strands every live terminal. Rollback crossing is reported, not enforced. +// Usage: node check-daemon-protocol-crossing.mjs [--release-ref ] +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { + DAEMON_PROTOCOL_SOURCE_PATH, + canAttach, + crossingRequirements, + parseDaemonProtocolFacts +} from './daemon-protocol-facts.mjs' +import { selectLatestStableReleaseTag } from './stable-release-tags.mjs' + +// Same override the cross-version-wire harness honors, so both pair against one ref. +const RELEASE_REF_ENV = 'ORCA_CROSS_VERSION_BASELINE_REF' + +function git(repoRoot, args) { + return execFileSync('git', args, { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 16 * 1024 * 1024 + }).trim() +} + +export function resolveReleaseRef(repoRoot, explicitRef, env = process.env) { + const override = explicitRef?.trim() || env[RELEASE_REF_ENV]?.trim() + if (override) { + return override + } + const tags = git(repoRoot, ['tag', '--list', 'v[0-9]*']).split('\n').filter(Boolean) + const latest = selectLatestStableReleaseTag(tags) + if (!latest) { + // Why: a shallow clone has no tags; passing here would silently skip the gate. + throw new Error( + `no stable release tags matching vX.Y.Z (saw ${tags.length} tag(s)). ` + + `Check out with fetch-depth: 0, or pass --release-ref / ${RELEASE_REF_ENV}.` + ) + } + return latest +} + +/** + * @param {{ release: ReturnType, candidate: ReturnType, releaseRef: string }} input + */ +export function assessDaemonProtocolCrossing({ release, candidate, releaseRef }) { + const upgrade = canAttach(candidate, release) + const rollback = canAttach(release, candidate) + const requirements = crossingRequirements(release) + const lines = [ + `release ${releaseRef}: daemon protocol ${release.protocolVersion}`, + `candidate (working tree): daemon protocol ${candidate.protocolVersion}`, + upgrade + ? `upgrade ${releaseRef} -> candidate: OK, live terminals are adopted` + : `upgrade ${releaseRef} -> candidate: FAIL, ${requirements.upgrade}; add ${release.protocolVersion} to PREVIOUS_DAEMON_PROTOCOL_VERSIONS`, + rollback + ? `rollback candidate -> ${releaseRef}: OK, live terminals survive (info)` + : `rollback candidate -> ${releaseRef}: terminals from the candidate's daemon are unreachable until re-upgrade (info, expected after a protocol bump)` + ] + return { upgrade, rollback, lines } +} + +export function checkDaemonProtocolCrossing({ repoRoot, releaseRef: explicitRef, env }) { + const releaseRef = resolveReleaseRef(repoRoot, explicitRef, env) + let releaseSource + try { + releaseSource = git(repoRoot, ['show', `${releaseRef}:${DAEMON_PROTOCOL_SOURCE_PATH}`]) + } catch (error) { + throw new Error( + `cannot read ${DAEMON_PROTOCOL_SOURCE_PATH} at ${releaseRef}: ${error.stderr?.trim() || String(error)}` + ) + } + const release = parseDaemonProtocolFacts( + releaseSource, + `${releaseRef}:${DAEMON_PROTOCOL_SOURCE_PATH}` + ) + const candidate = parseDaemonProtocolFacts( + readFileSync(join(repoRoot, DAEMON_PROTOCOL_SOURCE_PATH), 'utf8'), + DAEMON_PROTOCOL_SOURCE_PATH + ) + return assessDaemonProtocolCrossing({ release, candidate, releaseRef }) +} + +function parseArgs(argv) { + const index = argv.indexOf('--release-ref') + if (index === -1) { + return {} + } + const value = argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error('--release-ref requires a value') + } + return { releaseRef: value } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const repoRoot = resolve(import.meta.dirname, '..', '..') + try { + const { releaseRef } = parseArgs(process.argv.slice(2)) + const result = checkDaemonProtocolCrossing({ repoRoot, releaseRef }) + for (const line of result.lines) { + console.log(line) + } + if (!result.upgrade) { + process.exitCode = 1 + } + } catch (error) { + console.error(`daemon protocol crossing check failed: ${error.message}`) + process.exitCode = 1 + } +} diff --git a/config/scripts/check-daemon-protocol-crossing.test.mjs b/config/scripts/check-daemon-protocol-crossing.test.mjs new file mode 100644 index 000000000000..464b32ad3496 --- /dev/null +++ b/config/scripts/check-daemon-protocol-crossing.test.mjs @@ -0,0 +1,105 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { checkDaemonProtocolCrossing } from './check-daemon-protocol-crossing.mjs' +import { DAEMON_PROTOCOL_SOURCE_PATH } from './daemon-protocol-facts.mjs' +import { selectLatestStableReleaseTag } from './stable-release-tags.mjs' + +const repos = [] + +function git(repo, args) { + execFileSync('git', args, { cwd: repo, stdio: 'ignore' }) +} + +function writeProtocol(repo, current) { + const previous = Array.from({ length: current - 1 }, (_, index) => index + 1) + const file = join(repo, DAEMON_PROTOCOL_SOURCE_PATH) + mkdirSync(dirname(file), { recursive: true }) + writeFileSync( + file, + `export const PROTOCOL_VERSION = ${current}\nexport const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [${previous.join(', ')}] as const\n` + ) +} + +function repoWithTags(tagged) { + const repo = mkdtempSync(join(tmpdir(), 'daemon-protocol-crossing-')) + repos.push(repo) + git(repo, ['init', '-q']) + for (const [tag, protocol] of tagged) { + writeProtocol(repo, protocol) + git(repo, ['add', '.']) + git(repo, [ + '-c', + 'user.name=test', + '-c', + 'user.email=test@example.com', + 'commit', + '-q', + '--no-gpg-sign', + '-m', + tag + ]) + git(repo, ['tag', tag]) + } + return repo +} + +afterEach(() => { + for (const repo of repos.splice(0)) { + rmSync(repo, { recursive: true, force: true }) + } +}) + +describe('selectLatestStableReleaseTag', () => { + it('orders numerically and skips prerelease tags', () => { + expect(selectLatestStableReleaseTag(['v1.4.9', 'v1.4.10', 'v1.5.0-rc.1', 'nightly'])).toBe( + 'v1.4.10' + ) + expect(selectLatestStableReleaseTag(['nightly'])).toBeNull() + }) +}) + +describe('checkDaemonProtocolCrossing', () => { + const env = {} + + it('passes a bump that lists the newest release version, reporting rollback as info', () => { + const repo = repoWithTags([ + ['v1.0.9', 5], + ['v1.0.10', 6] + ]) + writeProtocol(repo, 7) + const result = checkDaemonProtocolCrossing({ repoRoot: repo, env }) + expect(result).toMatchObject({ upgrade: true, rollback: false }) + expect(result.lines[0]).toBe('release v1.0.10: daemon protocol 6') + }) + + it('fails when the working tree drops the newest release version', () => { + const repo = repoWithTags([['v1.0.0', 6]]) + writeFileSync( + join(repo, DAEMON_PROTOCOL_SOURCE_PATH), + 'export const PROTOCOL_VERSION = 7\nexport const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4, 5] as const\n' + ) + const result = checkDaemonProtocolCrossing({ repoRoot: repo, env }) + expect(result.upgrade).toBe(false) + expect(result.lines.join('\n')).toContain('add 6 to PREVIOUS_DAEMON_PROTOCOL_VERSIONS') + }) + + it('refuses to pass silently without release tags', () => { + const repo = repoWithTags([['nightly', 6]]) + expect(() => checkDaemonProtocolCrossing({ repoRoot: repo, env })).toThrow( + /no stable release tags/ + ) + }) + + it('honors an explicit release ref', () => { + const repo = repoWithTags([ + ['v1.0.0', 5], + ['v1.0.1', 6] + ]) + const result = checkDaemonProtocolCrossing({ repoRoot: repo, releaseRef: 'v1.0.0', env }) + expect(result).toMatchObject({ upgrade: true, rollback: false }) + expect(result.lines[0]).toBe('release v1.0.0: daemon protocol 5') + }) +}) diff --git a/config/scripts/check-node-runtime-pin.mjs b/config/scripts/check-node-runtime-pin.mjs new file mode 100644 index 000000000000..6d5205aa525c --- /dev/null +++ b/config/scripts/check-node-runtime-pin.mjs @@ -0,0 +1,182 @@ +#!/usr/bin/env node +// Static, offline consistency gate for src/shared/node-runtime-pin.ts; update-node-runtime-pin.mjs owns the network. + +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { parseAllDocuments } from 'yaml' +import { nodeDistArchiveName, windowsImportLibFile } from './node-dist-archive-name.mjs' + +// Why require: an ESM import of a .ts file under a typeless package.json prints MODULE_TYPELESS_PACKAGE_JSON. +const { + COMPAT_SERVER_TARGETS, + NODE_RUNTIME_ASSETS, + NODE_RUNTIME_COMPAT_ASSETS, + NODE_RUNTIME_PIN, + SERVER_TARGETS +} = createRequire(import.meta.url)('../../src/shared/node-runtime-pin.ts') + +const SHA256 = /^[0-9a-f]{64}$/ +const ASSET_SOURCES = new Set(['official', 'unofficial']) + +function majorOf(range) { + const match = /(\d+)/.exec(String(range ?? '')) + return match ? Number(match[1]) : null +} + +/** Strips pnpm's peer suffix: `43.7.5(supports-color@7.2.0)` -> `43.7.5`. */ +function lockedVersion(entry) { + const version = typeof entry === 'string' ? entry : entry?.version + return typeof version === 'string' ? version.replace(/\(.*$/, '') : null +} + +/** pnpm 12 splits the lockfile into a package-manager document and the project one; merge both. */ +export function lockfileRootImporter(contents) { + const importer = {} + for (const document of parseAllDocuments(contents)) { + if (document.errors.length) { + throw document.errors[0] + } + Object.assign(importer, document.toJS()?.importers?.['.']) + } + return importer +} + +function findAssetTableProblems({ pin, assets, targets, table, targetList }) { + const problems = [] + const expected = new Set(targets) + for (const target of targets) { + if (!Object.hasOwn(assets, target)) { + problems.push(`${table} has no entry for ${target}`) + } + } + for (const [target, asset] of Object.entries(assets)) { + if (!expected.has(target)) { + problems.push(`${table} has ${target}, which is not in ${targetList}`) + continue + } + if (!ASSET_SOURCES.has(asset.source)) { + problems.push(`${target}: source must be official or unofficial, got ${asset.source}`) + } + const expectedArchive = nodeDistArchiveName(pin.version, target) + if (asset.archive !== expectedArchive) { + problems.push(`${target}: archive ${asset.archive} is not ${expectedArchive}`) + } + if (!SHA256.test(asset.archiveSha256 ?? '')) { + problems.push(`${target}: archiveSha256 is not a 64-character hex SHA-256`) + } + if (!SHA256.test(asset.executableSha256 ?? '')) { + problems.push(`${target}: executableSha256 is not a 64-character hex SHA-256`) + } + if (!Number.isInteger(asset.executableSize) || asset.executableSize <= 0) { + problems.push(`${target}: executableSize must be a positive integer`) + } + } + return problems +} + +export function findNodeRuntimePinProblems({ + pin, + assets, + targets, + compatAssets = {}, + compatTargets = [], + packageJson, + rootImporter +}) { + const problems = [] + const declaredElectron = + packageJson.devDependencies?.electron ?? packageJson.dependencies?.electron + if (declaredElectron !== pin.electron) { + problems.push( + `package.json electron is ${declaredElectron}, but NODE_RUNTIME_PIN.electron is ${pin.electron}` + ) + } + const lockedElectron = lockedVersion( + rootImporter.devDependencies?.electron ?? rootImporter.dependencies?.electron + ) + if (lockedElectron !== pin.electron) { + problems.push( + `pnpm-lock.yaml resolves electron ${lockedElectron}, but NODE_RUNTIME_PIN.electron is ${pin.electron}` + ) + } + // Only the major is gated here; whether the pin may differ from Electron's Node is design D1 + // (docs/reference/node-runtime-design.html). + const engineMajor = majorOf(packageJson.engines?.node) + if (majorOf(pin.version) !== engineMajor) { + problems.push( + `NODE_RUNTIME_PIN.version ${pin.version} is not package.json engines.node major ${engineMajor}` + ) + } + if (!Number.isInteger(pin.napi) || pin.napi < 1) { + problems.push(`NODE_RUNTIME_PIN.napi must be a positive integer, got ${pin.napi}`) + } + if (!SHA256.test(pin.headers?.sha256 ?? '')) { + problems.push('NODE_RUNTIME_PIN.headers.sha256 is not a 64-character hex SHA-256') + } + if (pin.headers?.file !== `node-v${pin.version}-headers.tar.gz`) { + problems.push(`NODE_RUNTIME_PIN.headers.file ${pin.headers?.file} is not for ${pin.version}`) + } + for (const target of targets.filter((name) => name.startsWith('win32-'))) { + const lib = pin.windowsImportLibs?.[target] + if (lib?.file !== windowsImportLibFile(target)) { + problems.push( + `NODE_RUNTIME_PIN.windowsImportLibs.${target}.file is not ${windowsImportLibFile(target)}` + ) + } + if (!SHA256.test(lib?.sha256 ?? '')) { + problems.push( + `NODE_RUNTIME_PIN.windowsImportLibs.${target}.sha256 is not a 64-character hex SHA-256` + ) + } + } + + problems.push( + ...findAssetTableProblems({ + pin, + assets, + targets, + table: 'NODE_RUNTIME_ASSETS', + targetList: 'SERVER_TARGETS' + }), + ...findAssetTableProblems({ + pin, + assets: compatAssets, + targets: compatTargets, + table: 'NODE_RUNTIME_COMPAT_ASSETS', + targetList: 'COMPAT_SERVER_TARGETS' + }) + ) + return problems +} + +export function main(root = resolve(import.meta.dirname, '../..')) { + const problems = findNodeRuntimePinProblems({ + pin: NODE_RUNTIME_PIN, + assets: NODE_RUNTIME_ASSETS, + targets: SERVER_TARGETS, + compatAssets: NODE_RUNTIME_COMPAT_ASSETS, + compatTargets: COMPAT_SERVER_TARGETS, + packageJson: JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')), + rootImporter: lockfileRootImporter(readFileSync(join(root, 'pnpm-lock.yaml'), 'utf8')) + }) + if (problems.length > 0) { + console.error('Node runtime pin check failed:') + for (const problem of problems) { + console.error(`- ${problem}`) + } + console.error( + 'Regenerate with: node config/scripts/update-node-runtime-pin.mjs --version ' + ) + return 1 + } + console.log( + `Node runtime pin check passed: Node ${NODE_RUNTIME_PIN.version} for Electron ${NODE_RUNTIME_PIN.electron}.` + ) + return 0 +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exit(main()) +} diff --git a/config/scripts/check-node-runtime-pin.test.mjs b/config/scripts/check-node-runtime-pin.test.mjs new file mode 100644 index 000000000000..e1be15f0f7fe --- /dev/null +++ b/config/scripts/check-node-runtime-pin.test.mjs @@ -0,0 +1,195 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' +import { + NODE_RUNTIME_ASSETS, + NODE_RUNTIME_PIN, + SERVER_TARGETS +} from '../../src/shared/node-runtime-pin.ts' +import { + findNodeRuntimePinProblems, + lockfileRootImporter, + main +} from './check-node-runtime-pin.mjs' + +const projectDir = path.resolve(import.meta.dirname, '../..') +const HASH = 'a'.repeat(64) + +function validInput() { + const pin = { + version: '24.21.0', + electron: '43.7.5', + napi: 10, + headers: { file: 'node-v24.21.0-headers.tar.gz', sha256: HASH }, + windowsImportLibs: { 'win32-x64': { file: 'win-x64/node.lib', sha256: HASH } } + } + const targets = ['linux-x64-glibc', 'win32-x64'] + const assets = { + 'linux-x64-glibc': { + source: 'official', + archive: 'node-v24.21.0-linux-x64.tar.gz', + archiveSha256: HASH, + executableSha256: HASH, + executableSize: 1 + }, + 'win32-x64': { + source: 'official', + archive: 'node-v24.21.0-win-x64.zip', + archiveSha256: HASH, + executableSha256: HASH, + executableSize: 1 + } + } + return { + pin, + assets, + targets, + packageJson: { devDependencies: { electron: '43.7.5' }, engines: { node: '24' } }, + rootImporter: { + devDependencies: { + electron: { specifier: '43.7.5', version: '43.7.5(supports-color@7.2.0)' } + } + } + } +} + +describe('findNodeRuntimePinProblems', () => { + it('accepts a consistent pin', () => { + expect(findNodeRuntimePinProblems(validInput())).toEqual([]) + }) + + it('rejects an Electron bump that the pin did not follow', () => { + const input = validInput() + input.packageJson.devDependencies.electron = '43.8.0' + input.rootImporter.devDependencies.electron.version = '43.8.0' + expect(findNodeRuntimePinProblems(input)).toEqual([ + 'package.json electron is 43.8.0, but NODE_RUNTIME_PIN.electron is 43.7.5', + 'pnpm-lock.yaml resolves electron 43.8.0, but NODE_RUNTIME_PIN.electron is 43.7.5' + ]) + }) + + it('rejects a lockfile that resolves a different Electron than package.json', () => { + const input = validInput() + input.rootImporter.devDependencies.electron.version = '43.7.6' + expect(findNodeRuntimePinProblems(input)).toEqual([ + 'pnpm-lock.yaml resolves electron 43.7.6, but NODE_RUNTIME_PIN.electron is 43.7.5' + ]) + }) + + it('rejects a pin major that differs from engines.node', () => { + const input = validInput() + input.packageJson.engines.node = '>=26' + expect(findNodeRuntimePinProblems(input)).toEqual([ + 'NODE_RUNTIME_PIN.version 24.21.0 is not package.json engines.node major 26' + ]) + }) + + it('requires exactly one asset per server target', () => { + const input = validInput() + delete input.assets['win32-x64'] + input.assets['freebsd-x64'] = input.assets['linux-x64-glibc'] + expect(findNodeRuntimePinProblems(input)).toEqual([ + 'NODE_RUNTIME_ASSETS has no entry for win32-x64', + 'NODE_RUNTIME_ASSETS has freebsd-x64, which is not in SERVER_TARGETS' + ]) + }) + + it('rejects malformed hashes, sizes, sources and stale archive names', () => { + const input = validInput() + input.pin.headers.sha256 = 'ABC' + input.assets['linux-x64-glibc'] = { + source: 'mirror', + archive: 'node-v24.20.0-linux-x64.tar.gz', + archiveSha256: HASH.toUpperCase(), + executableSha256: `${HASH}0`, + executableSize: 0 + } + expect(findNodeRuntimePinProblems(input)).toEqual([ + 'NODE_RUNTIME_PIN.headers.sha256 is not a 64-character hex SHA-256', + 'linux-x64-glibc: source must be official or unofficial, got mirror', + 'linux-x64-glibc: archive node-v24.20.0-linux-x64.tar.gz is not node-v24.21.0-linux-x64.tar.gz', + 'linux-x64-glibc: archiveSha256 is not a 64-character hex SHA-256', + 'linux-x64-glibc: executableSha256 is not a 64-character hex SHA-256', + 'linux-x64-glibc: executableSize must be a positive integer' + ]) + }) + it('rejects a missing or mistargeted Windows node.lib', () => { + const input = validInput() + input.pin.windowsImportLibs['win32-x64'] = { file: 'win-arm64/node.lib', sha256: 'x' } + expect(findNodeRuntimePinProblems(input)).toEqual([ + 'NODE_RUNTIME_PIN.windowsImportLibs.win32-x64.file is not win-x64/node.lib', + 'NODE_RUNTIME_PIN.windowsImportLibs.win32-x64.sha256 is not a 64-character hex SHA-256' + ]) + }) + it('checks the compat table against its own target list', () => { + const input = validInput() + input.compatTargets = ['linux-x64-glibc217'] + input.compatAssets = { + 'linux-x64-glibc217': { + source: 'unofficial', + archive: 'node-v24.21.0-linux-x64.tar.gz', + archiveSha256: HASH, + executableSha256: HASH, + executableSize: 1 + }, + 'linux-x64-glibc': input.assets['linux-x64-glibc'] + } + expect(findNodeRuntimePinProblems(input)).toEqual([ + 'linux-x64-glibc217: archive node-v24.21.0-linux-x64.tar.gz is not node-v24.21.0-linux-x64-glibc-217.tar.gz', + 'NODE_RUNTIME_COMPAT_ASSETS has linux-x64-glibc, which is not in COMPAT_SERVER_TARGETS' + ]) + delete input.compatAssets['linux-x64-glibc217'] + expect(findNodeRuntimePinProblems(input)).toContain( + 'NODE_RUNTIME_COMPAT_ASSETS has no entry for linux-x64-glibc217' + ) + }) + it("rejects another target's archive", () => { + const input = validInput() + input.assets['win32-x64'].archive = 'node-v24.21.0-win-arm64.zip' + expect(findNodeRuntimePinProblems(input)).toEqual([ + 'win32-x64: archive node-v24.21.0-win-arm64.zip is not node-v24.21.0-win-x64.zip' + ]) + }) +}) + +describe('lockfileRootImporter', () => { + it('merges the root importer across pnpm 12 lockfile documents', () => { + const contents = [ + '---', + "lockfileVersion: '9.0'", + 'importers:', + ' .:', + ' packageManagerDependencies: {}', + '---', + "lockfileVersion: '9.0'", + 'importers:', + ' .:', + ' devDependencies:', + ' electron:', + ' specifier: 43.7.5', + ' version: 43.7.5(supports-color@7.2.0)', + '' + ].join('\n') + expect(lockfileRootImporter(contents).devDependencies.electron.version).toBe( + '43.7.5(supports-color@7.2.0)' + ) + }) +}) + +describe('committed pin', () => { + it('passes the repository check', () => { + expect(main(projectDir)).toBe(0) + }) + + it('covers every server target', () => { + expect(Object.keys(NODE_RUNTIME_ASSETS).sort()).toEqual([...SERVER_TARGETS].sort()) + expect(NODE_RUNTIME_PIN.headers.file).toBe(`node-v${NODE_RUNTIME_PIN.version}-headers.tar.gz`) + }) + + it('runs in the static analysis job', () => { + const workflow = parse(readFileSync(path.join(projectDir, '.github/workflows/pr.yml'), 'utf8')) + const commands = workflow.jobs.static_analysis.steps.map((step) => step.run ?? '') + expect(commands).toContain('pnpm run check:node-runtime-pin') + }) +}) diff --git a/config/scripts/check-runtime-launcher-protocol-ratchet.mjs b/config/scripts/check-runtime-launcher-protocol-ratchet.mjs new file mode 100644 index 000000000000..a26a5d87aa31 --- /dev/null +++ b/config/scripts/check-runtime-launcher-protocol-ratchet.mjs @@ -0,0 +1,129 @@ +// R3 gate (docs/reference/node-runtime-design.html, D7.1): swapping the process that hosts orcad +// or the daemon is not a daemon protocol change, so one PR must not do both. A bump riding along +// with a runtime swap would strand every live terminal on upgrade and rollback at once. +// Usage: node check-runtime-launcher-protocol-ratchet.mjs --base +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { DAEMON_PROTOCOL_SOURCE_PATH, parseDaemonProtocolFacts } from './daemon-protocol-facts.mjs' + +/** Files that decide which runtime executable launches orcad or the terminal daemon. */ +export const RUNTIME_LAUNCHER_PATHS = [ + // orcad handoff to its bundled runtime, and the pinned runtimes it can hand off to. + 'src/main/orcad/orcad-bundled-runtime.ts', + 'src/shared/node-runtime-pin.ts', + 'src/main/ssh/pinned-runtime-materializer.ts', + 'src/main/ssh/runtime-archive-download.ts', + 'src/main/ssh/orcad-remote-node-runtime.ts', + // orcad slot layout: which runtime file a packaged slot carries. + 'src/shared/orcad-artifacts.ts', + 'config/scripts/build-orcad.mjs', + 'config/scripts/build-orcad-node.mjs', + 'config/scripts/build-orcad-template.mjs', + // Remote slot runtime selection. + 'src/main/ssh/orcad-remote-runtime.ts', + // Terminal daemon host launch: which executable the daemon child is forked from. + 'src/main/daemon/daemon-launched-child.ts', + 'src/main/daemon/daemon-launched-child-spawn.ts', + 'src/main/daemon/daemon-out-of-process-launcher.ts', + 'src/main/daemon/daemon-host-relocation.ts', + 'src/main/daemon/daemon-host-manifest.ts' +] + +export const RUNTIME_PROTOCOL_OVERRIDE_ENV = 'ORCA_ALLOW_RUNTIME_LAUNCHER_PROTOCOL_BUMP' +export const RUNTIME_PROTOCOL_OVERRIDE_LABEL = 'allow-runtime-launcher-protocol-bump' + +function git(repoRoot, args) { + return execFileSync('git', args, { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 16 * 1024 * 1024 + }) +} + +/** + * @param {{ changedFiles: string[], baseProtocolVersion: number, candidateProtocolVersion: number, overridden: boolean }} input + */ +export function assessRuntimeLauncherProtocolRatchet({ + changedFiles, + baseProtocolVersion, + candidateProtocolVersion, + overridden +}) { + const launcherChanges = RUNTIME_LAUNCHER_PATHS.filter((path) => changedFiles.includes(path)) + const protocolChanged = baseProtocolVersion !== candidateProtocolVersion + const violated = protocolChanged && launcherChanges.length > 0 + const lines = [ + `daemon PROTOCOL_VERSION: base ${baseProtocolVersion}, candidate ${candidateProtocolVersion}`, + launcherChanges.length > 0 + ? `runtime launcher files changed: ${launcherChanges.join(', ')}` + : 'runtime launcher files changed: none' + ] + if (!violated) { + lines.push('OK: this change does not both swap a runtime launcher and bump the daemon protocol') + } else if (overridden) { + lines.push( + `OVERRIDDEN: launcher change and protocol bump together, allowed by ${RUNTIME_PROTOCOL_OVERRIDE_ENV}` + ) + } else { + lines.push( + 'FAIL: split the protocol bump and the runtime launcher change into separate PRs (D7.1 R3), ' + + `or apply the "${RUNTIME_PROTOCOL_OVERRIDE_LABEL}" label and re-run the workflow with a new push.` + ) + } + return { ok: !violated || overridden, violated, lines } +} + +export function isOverrideEnabled(env = process.env) { + return ['1', 'true'].includes(env[RUNTIME_PROTOCOL_OVERRIDE_ENV]?.trim().toLowerCase() ?? '') +} + +export function checkRuntimeLauncherProtocolRatchet({ repoRoot, base, env = process.env }) { + // Why diff against the working tree: CI's tree is HEAD, and a local run also sees unstaged edits. + const changedFiles = git(repoRoot, ['diff', '--name-only', '--no-renames', base, '--']) + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + const baseFacts = parseDaemonProtocolFacts( + git(repoRoot, ['show', `${base}:${DAEMON_PROTOCOL_SOURCE_PATH}`]), + `${base}:${DAEMON_PROTOCOL_SOURCE_PATH}` + ) + const candidateFacts = parseDaemonProtocolFacts( + readFileSync(join(repoRoot, DAEMON_PROTOCOL_SOURCE_PATH), 'utf8'), + DAEMON_PROTOCOL_SOURCE_PATH + ) + return assessRuntimeLauncherProtocolRatchet({ + changedFiles, + baseProtocolVersion: baseFacts.protocolVersion, + candidateProtocolVersion: candidateFacts.protocolVersion, + overridden: isOverrideEnabled(env) + }) +} + +function parseArgs(argv) { + const index = argv.indexOf('--base') + const value = index === -1 ? undefined : argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error('--base is required (the pull request diff base)') + } + return { base: value } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const repoRoot = resolve(import.meta.dirname, '..', '..') + try { + const { base } = parseArgs(process.argv.slice(2)) + const result = checkRuntimeLauncherProtocolRatchet({ repoRoot, base }) + for (const line of result.lines) { + console.log(line) + } + if (!result.ok) { + process.exitCode = 1 + } + } catch (error) { + console.error(`runtime launcher protocol ratchet failed: ${error.message}`) + process.exitCode = 1 + } +} diff --git a/config/scripts/check-runtime-launcher-protocol-ratchet.test.mjs b/config/scripts/check-runtime-launcher-protocol-ratchet.test.mjs new file mode 100644 index 000000000000..bf7cdb996386 --- /dev/null +++ b/config/scripts/check-runtime-launcher-protocol-ratchet.test.mjs @@ -0,0 +1,181 @@ +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + RUNTIME_LAUNCHER_PATHS, + RUNTIME_PROTOCOL_OVERRIDE_ENV, + assessRuntimeLauncherProtocolRatchet, + checkRuntimeLauncherProtocolRatchet, + isOverrideEnabled +} from './check-runtime-launcher-protocol-ratchet.mjs' +import { DAEMON_PROTOCOL_SOURCE_PATH } from './daemon-protocol-facts.mjs' +import { classifyPrJobs } from './pr-code-change-scope.mjs' + +const repoRoot = resolve(import.meta.dirname, '..', '..') +const repos = [] +const LAUNCHER = 'src/main/ssh/orcad-remote-runtime.ts' + +function git(repo, args) { + return execFileSync('git', args, { + cwd: repo, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }) +} + +function write(repo, path, contents) { + const file = join(repo, path) + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, contents) +} + +function writeProtocol(repo, current) { + const previous = Array.from({ length: current - 1 }, (_, index) => index + 1) + write( + repo, + DAEMON_PROTOCOL_SOURCE_PATH, + `export const PROTOCOL_VERSION = ${current}\nexport const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [${previous.join(', ')}] as const\n` + ) +} + +function commit(repo, message) { + git(repo, ['add', '.']) + git(repo, [ + '-c', + 'user.name=test', + '-c', + 'user.email=test@example.com', + 'commit', + '-q', + '--no-gpg-sign', + '-m', + message + ]) + return git(repo, ['rev-parse', 'HEAD']).trim() +} + +/** A base commit at protocol 3, then a candidate commit shaped by `change`. */ +function repoWithCandidate(change) { + const repo = mkdtempSync(join(tmpdir(), 'runtime-launcher-ratchet-')) + repos.push(repo) + git(repo, ['init', '-q']) + writeProtocol(repo, 3) + write(repo, LAUNCHER, 'export const launcher = 1\n') + write(repo, 'src/unrelated.ts', 'export const unrelated = 1\n') + const base = commit(repo, 'base') + change(repo) + commit(repo, 'candidate') + return { repo, base } +} + +afterEach(() => { + for (const repo of repos.splice(0)) { + rmSync(repo, { recursive: true, force: true }) + } +}) + +describe('RUNTIME_LAUNCHER_PATHS', () => { + it('names only files that exist, so a rename cannot silently drop one from the gate', () => { + expect(RUNTIME_LAUNCHER_PATHS.filter((path) => !existsSync(join(repoRoot, path)))).toEqual([]) + }) +}) + +describe('PR routing', () => { + it('runs the ratchet job when its checker or the protocol changes', () => { + for (const file of [ + 'config/scripts/check-runtime-launcher-protocol-ratchet.mjs', + DAEMON_PROTOCOL_SOURCE_PATH + ]) { + expect(classifyPrJobs([file])['cross-version-wire']).toBe(true) + } + }) +}) + +describe('assessRuntimeLauncherProtocolRatchet', () => { + const launcherAndBump = { + changedFiles: [LAUNCHER, DAEMON_PROTOCOL_SOURCE_PATH], + baseProtocolVersion: 3, + candidateProtocolVersion: 4 + } + + it('fails a launcher change that also bumps the protocol', () => { + expect( + assessRuntimeLauncherProtocolRatchet({ ...launcherAndBump, overridden: false }) + ).toMatchObject({ + ok: false, + violated: true + }) + }) + + it('lets an explicit override through while still reporting the violation', () => { + const result = assessRuntimeLauncherProtocolRatchet({ ...launcherAndBump, overridden: true }) + expect(result).toMatchObject({ ok: true, violated: true }) + expect(result.lines.at(-1)).toContain('OVERRIDDEN') + }) + + it('passes a protocol bump without a launcher change, and a launcher change without a bump', () => { + expect( + assessRuntimeLauncherProtocolRatchet({ + ...launcherAndBump, + changedFiles: [DAEMON_PROTOCOL_SOURCE_PATH], + overridden: false + }).ok + ).toBe(true) + expect( + assessRuntimeLauncherProtocolRatchet({ + ...launcherAndBump, + candidateProtocolVersion: 3, + overridden: false + }).ok + ).toBe(true) + }) +}) + +describe('isOverrideEnabled', () => { + it('accepts only an explicit true', () => { + expect(isOverrideEnabled({ [RUNTIME_PROTOCOL_OVERRIDE_ENV]: 'true' })).toBe(true) + expect(isOverrideEnabled({ [RUNTIME_PROTOCOL_OVERRIDE_ENV]: '1' })).toBe(true) + expect(isOverrideEnabled({ [RUNTIME_PROTOCOL_OVERRIDE_ENV]: 'false' })).toBe(false) + expect(isOverrideEnabled({ [RUNTIME_PROTOCOL_OVERRIDE_ENV]: '' })).toBe(false) + expect(isOverrideEnabled({})).toBe(false) + }) +}) + +describe('checkRuntimeLauncherProtocolRatchet against git history', () => { + it('fails when the diff from the base both edits a launcher and bumps PROTOCOL_VERSION', () => { + const { repo, base } = repoWithCandidate((candidate) => { + writeProtocol(candidate, 4) + write(candidate, LAUNCHER, 'export const launcher = 2\n') + }) + expect(checkRuntimeLauncherProtocolRatchet({ repoRoot: repo, base, env: {} }).ok).toBe(false) + expect( + checkRuntimeLauncherProtocolRatchet({ + repoRoot: repo, + base, + env: { [RUNTIME_PROTOCOL_OVERRIDE_ENV]: 'true' } + }).ok + ).toBe(true) + }) + + it('passes a bump next to unrelated edits', () => { + const { repo, base } = repoWithCandidate((candidate) => { + writeProtocol(candidate, 4) + write(candidate, 'src/unrelated.ts', 'export const unrelated = 2\n') + }) + expect(checkRuntimeLauncherProtocolRatchet({ repoRoot: repo, base, env: {} }).ok).toBe(true) + }) + + it('ignores edits to the protocol file that keep PROTOCOL_VERSION', () => { + const { repo, base } = repoWithCandidate((candidate) => { + write( + candidate, + DAEMON_PROTOCOL_SOURCE_PATH, + '// comment\nexport const PROTOCOL_VERSION = 3\nexport const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2] as const\n' + ) + write(candidate, LAUNCHER, 'export const launcher = 2\n') + }) + expect(checkRuntimeLauncherProtocolRatchet({ repoRoot: repo, base, env: {} }).ok).toBe(true) + }) +}) diff --git a/config/scripts/ci-background-step-barriers.test.mjs b/config/scripts/ci-background-step-barriers.test.mjs index f731d9d08cb3..d9dc6332a9f0 100644 --- a/config/scripts/ci-background-step-barriers.test.mjs +++ b/config/scripts/ci-background-step-barriers.test.mjs @@ -20,6 +20,7 @@ describe('CI background step barriers', () => { it('joins every background check without suppressing failures', () => { for (const job of [ pr.jobs.static_analysis, + pr.jobs.typecheck, pr.jobs.mobile_web_app, pr.jobs.package, pr.jobs.shell_contracts, @@ -47,6 +48,14 @@ describe('CI background step barriers', () => { } }) + it('joins planning before publishing the unit artifact', () => { + assertJoinedBefore( + pr.jobs.typecheck.steps, + 'unit-plan', + (step) => step.uses === 'actions/upload-artifact@v7' + ) + }) + it('finishes native import-cycle analysis before mobile installation changes resolution', () => { const steps = pr.jobs.static_analysis.steps assertJoinedBefore(steps, 'native-code-quality', (step) => diff --git a/config/scripts/ci-cache-warmup-workflow.test.mjs b/config/scripts/ci-cache-warmup-workflow.test.mjs index 322cf982d828..4b20c04b1570 100644 --- a/config/scripts/ci-cache-warmup-workflow.test.mjs +++ b/config/scripts/ci-cache-warmup-workflow.test.mjs @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs' import { expect, it } from 'vitest' import { parse } from 'yaml' -import { BUN_PERSISTENCE_RUNNERS } from './bun-profile-qualification.mjs' +import { NODE_SERVER_RUNNERS } from './node-server-qualification.mjs' const readWorkflow = (name) => parse(readFileSync(new URL(`../../.github/workflows/${name}.yml`, import.meta.url), 'utf8')) @@ -13,7 +13,7 @@ it('warms the same Linux Node runtime the PR shards restore', () => { const install = arm.steps.find( (step) => step.uses === './.github/actions/install-node-dependencies' ) - const primer = readWorkflow('pr').jobs.test_native_cache + const primer = readWorkflow('pr').jobs.static_analysis expect(arm['runs-on']).toBe(primer['runs-on']) expect(arm.steps.at(-1).run).toBe('node config/scripts/ensure-native-runtime.mjs --check-only') expect(install.with).toMatchObject(primer.steps.find((step) => step.uses === install.uses).with) @@ -66,7 +66,7 @@ it('bounds warming to the required platforms and validates changes without grant it('warms and probes both Windows images with the persistence job runtime', () => { const job = workflow.jobs['warm-windows'] expect(job.strategy.matrix.os).toEqual( - BUN_PERSISTENCE_RUNNERS.filter((os) => os.startsWith('windows-')) + NODE_SERVER_RUNNERS.filter((os) => os.startsWith('windows-')) ) expect(job['runs-on']).toBe('${{ matrix.os }}') expect(job.strategy['fail-fast']).toBe(false) diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 1e855befdc57..7a3a3710d4eb 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -18,10 +18,8 @@ "config/scripts/build-mac-local.test.mjs": 52, "config/scripts/build-mobile-web-app-bundle.test.mjs": 213, "config/scripts/build-native-for-platform.test.mjs": 42, - "config/scripts/build-orcad-bun.test.mjs": 122, "config/scripts/build-orcad-prebuilds.test.mjs": 85, "config/scripts/build-windows-cli-launcher.test.mjs": 84, - "config/scripts/bun-profile-change-scope.test.mjs": 3355, "config/scripts/casting-code-quality.test.mjs": 874, "config/scripts/check-changed-code-quality.test.mjs": 79, "config/scripts/check-max-lines-ratchet.test.mjs": 945, @@ -175,6 +173,7 @@ "config/scripts/node-pty-console-list-agent-patch.test.mjs": 46, "config/scripts/node-pty-master-cloexec-patch.test.mjs": 114, "config/scripts/node-pty-windows-pty-teardown-patch.test.mjs": 98, + "config/scripts/node-server-change-scope.test.mjs": 3355, "config/scripts/nsis-process-check.test.mjs": 62, "config/scripts/orca-cli-skill-guidance.test.mjs": 62, "config/scripts/orca-dev-bin.test.mjs": 79, @@ -1312,7 +1311,7 @@ "src/main/codex/codex-trust-grant-telemetry.test.ts": 70, "src/main/codex/codex-turn-ordinals.test.ts": 96, "src/main/codex/codex-user-hook-trust-rebase-client.test.ts": 104, - "src/main/codex/codex-user-hook-trust-rebase.test.ts": 127, + "src/main/codex/codex-user-hook-trust-moves.test.ts": 127, "src/main/codex/codex-wsl-hook-install-plan.test.ts": 51, "src/main/codex/config-plugin-registration-promotion.test.ts": 370, "src/main/codex/config-settings-baseline-upgrade.test.ts": 104, @@ -1545,22 +1544,8 @@ "src/main/daemon/pty-subprocess-windows-shell-launch.test.ts": 310, "src/main/daemon/pty-subprocess-wsl-launch.test.ts": 370, "src/main/daemon/pty-subprocess.test.ts": 269, - "src/main/daemon/pty-subprocess/bun-pty-job-control.integration.test.ts": 27, - "src/main/daemon/pty-subprocess/bun-pty-process-flow-control.test.ts": 111, - "src/main/daemon/pty-subprocess/bun-pty-process-pause-retry.test.ts": 80, - "src/main/daemon/pty-subprocess/bun-pty-process-suspension.test.ts": 118, - "src/main/daemon/pty-subprocess/bun-pty-process.integration.test.ts": 49, - "src/main/daemon/pty-subprocess/bun-pty-process.test.ts": 352, - "src/main/daemon/pty-subprocess/native-pty-spawn-bun.test.ts": 70, "src/main/daemon/pty-subprocess/native-pty-spawn-windows.test.ts": 83, "src/main/daemon/pty-subprocess/spawn-file-foreground-rejected-agents.test.ts": 102, - "src/main/daemon/pty-subprocess/spawn-preflight-bun.test.ts": 91, - "src/main/daemon/pty-subprocess/windows-bun-pty-gate.integration.test.ts": 80, - "src/main/daemon/pty-subprocess/windows-bun-pty-gate.test.ts": 68, - "src/main/daemon/pty-subprocess/windows-bun-pty-job.test.ts": 53, - "src/main/daemon/pty-subprocess/windows-bun-pty-launch.test.ts": 70, - "src/main/daemon/pty-subprocess/windows-bun-pty-native.test.ts": 30, - "src/main/daemon/pty-subprocess/windows-bun-pty-spawn-receipt.test.ts": 70, "src/main/daemon/reattach-snapshot.test.ts": 878, "src/main/daemon/repro-12101-mouse-tracking-survives-agent-death.test.ts": 156, "src/main/daemon/repro-7329-remote-snapshot-corruption.test.ts": 122, @@ -1788,7 +1773,6 @@ "src/main/git/worktree-create-preparation-real-wsl.test.ts": 271, "src/main/git/worktree-created-description-real-git.test.ts": 1340, "src/main/git/worktree-created-disk-witness.test.ts": 149, - "src/main/git/worktree-deferred-removal-real-git.test.ts": 587, "src/main/git/worktree-diff-stamp-guest-gitdir.test.ts": 40, "src/main/git/worktree-diff-stamp-host-paths.test.ts": 55, "src/main/git/worktree-git-capabilities.test.ts": 248, @@ -1805,6 +1789,7 @@ "src/main/git/worktree-preparation-base-oid.test.ts": 99, "src/main/git/worktree-preparation-cancel-latency.bench.test.ts": 248, "src/main/git/worktree-removal-large-tree.bench.test.ts": 291, + "src/main/git/worktree-removal-real-git.test.ts": 5000, "src/main/git/worktree-remove-branch-deletion.test.ts": 194, "src/main/git/worktree-scan-cache-annotation-reuse.test.ts": 241, "src/main/git/worktree-scan-cache-sharing.test.ts": 284, @@ -2738,8 +2723,6 @@ "src/main/orcad/orcad-artifact-preflight.test.ts": 377, "src/main/orcad/orcad-bind-address.test.ts": 57, "src/main/orcad/orcad-browser-provider.test.ts": 336, - "src/main/orcad/orcad-bun-launcher.integration.test.ts": 50, - "src/main/orcad/orcad-bun-native-preflight.test.ts": 113, "src/main/orcad/orcad-bundle-native-load-order.test.ts": 2639, "src/main/orcad/orcad-bundled-runtime.integration.test.ts": 5450, "src/main/orcad/orcad-bundled-runtime.test.ts": 90, @@ -2750,10 +2733,12 @@ "src/main/orcad/orcad-instance-lock.test.ts": 91, "src/main/orcad/orcad-launch-contract.test.ts": 405, "src/main/orcad/orcad-native-preflight.test.ts": 71, + "src/main/orcad/orcad-node-launcher.integration.test.ts": 50, "src/main/orcad/orcad-profile-preflight.test.ts": 246, "src/main/orcad/orcad-profile-state-startup.test.ts": 55, "src/main/orcad/orcad-profile-state-telemetry.test.ts": 36, "src/main/orcad/orcad-push-startup.test.ts": 1287, + "src/main/orcad/orcad-runtime-native-preflight.test.ts": 113, "src/main/orcad/orcad-sidecar-runtime-client.test.ts": 211, "src/main/own-chromium-tree-kill-guard.test.ts": 99, "src/main/persistence-async-write-syscalls.test.ts": 3167, @@ -3024,9 +3009,6 @@ "src/main/providers/agent-foreground-process-wsl-shell-proof.test.ts": 108, "src/main/providers/agent-foreground-process.test.ts": 149, "src/main/providers/execution-host-provider-dispatch.test.ts": 33, - "src/main/providers/local-pty-bun-artifact.integration.test.ts": 50, - "src/main/providers/local-pty-bun-posix-inspection.test.ts": 238, - "src/main/providers/local-pty-bun-windows-identity.test.ts": 132, "src/main/providers/local-pty-child-process-verdict.test.ts": 280, "src/main/providers/local-pty-default-shell.test.ts": 124, "src/main/providers/local-pty-foreground-inspection-cheap-tier.test.ts": 140, @@ -4224,7 +4206,6 @@ "src/main/speech/stt-service.test.ts": 86, "src/main/speech/stt-worker-model-config.test.ts": 48, "src/main/speech/stt-worker.test.ts": 38, - "src/main/sqlite/bun-readonly-wal.test.ts": 137, "src/main/sqlite/sqlite-read-failure.test.ts": 60, "src/main/sqlite/sync-database-concurrent-backup.test.ts": 815, "src/main/sqlite/sync-database-portability.test.ts": 108, @@ -4234,7 +4215,6 @@ "src/main/ssh/orcad-activation-gate.test.ts": 56, "src/main/ssh/orcad-activation-record.test.ts": 44, "src/main/ssh/orcad-artifact-materializer.test.ts": 681, - "src/main/ssh/orcad-bun-runtime-materializer.test.ts": 357, "src/main/ssh/orcad-deployment-target.test.ts": 195, "src/main/ssh/orcad-remote-deploy.test.ts": 479, "src/main/ssh/orcad-remote-gc.test.ts": 161, @@ -4245,6 +4225,7 @@ "src/main/ssh/orcad-remote-shell-commands.integration.test.ts": 4806, "src/main/ssh/orcad-state-snapshot.test.ts": 135, "src/main/ssh/orcad-update-plan.test.ts": 62, + "src/main/ssh/pinned-runtime-materializer.test.ts": 357, "src/main/ssh/relay-native-dependency-coverage.test.ts": 504, "src/main/ssh/relay-protocol-backpressure.test.ts": 103, "src/main/ssh/relay-protocol.test.ts": 70, diff --git a/config/scripts/ci-unit-plan.mjs b/config/scripts/ci-unit-plan.mjs index db7a13070d01..117231d99bc1 100644 --- a/config/scripts/ci-unit-plan.mjs +++ b/config/scripts/ci-unit-plan.mjs @@ -25,7 +25,7 @@ export function prepareUnitPlan(env = process.env) { plan = planUnitSelection({ files, changed: diff.stdout.split('\0').filter(Boolean), - graph: collectUnitDependencyGraph(), + graph: collectUnitDependencyGraph, timings: readTimingBaseline('unit').timings, event, mode: env.ORCA_UNIT_SELECTION_MODE diff --git a/config/scripts/ci-unit-selection.mjs b/config/scripts/ci-unit-selection.mjs index 841bad422e70..b531ec069caa 100644 --- a/config/scripts/ci-unit-selection.mjs +++ b/config/scripts/ci-unit-selection.mjs @@ -1,12 +1,16 @@ import { unitConsumers } from './ci-unit-dependency-graph.mjs' import { balanceFiles } from './ci-shard-assignment.mjs' -export function selectUnitFiles(files, changed, graph) { +export function selectUnitFiles(files, changed, graphInput) { const full = (reason) => ({ files, reason, full: true }) if (!changed.length) { return full('Missing changed-path evidence') } - if (changed.some((file) => !file.startsWith('src/') || !graph.files.has(file))) { + if (changed.some((file) => !file.startsWith('src/'))) { + return full('Global, deleted, renamed or unknown input') + } + const graph = typeof graphInput === 'function' ? graphInput() : graphInput + if (changed.some((file) => !graph.files.has(file))) { return full('Global, deleted, renamed or unknown input') } for (const file of changed) { diff --git a/config/scripts/ci-unit-selection.test.mjs b/config/scripts/ci-unit-selection.test.mjs index 936b8874f659..2f2b7726111c 100644 --- a/config/scripts/ci-unit-selection.test.mjs +++ b/config/scripts/ci-unit-selection.test.mjs @@ -62,6 +62,32 @@ describe('conservative unit selection', () => { expect(selectUnitFiles(files, changed, graph)).toMatchObject({ files, full: true }) }) + it.each( + [[], ['pnpm-lock.yaml'], ['config/vitest.config.ts', 'src/leaf.ts']].map((changed) => ({ + changed + })) + )('keeps full coverage without reading the graph for global evidence: %j', ({ changed }) => { + const plan = planUnitSelection({ + files, + changed, + graph: () => { + throw new Error('Graph must not be read') + }, + timings: {}, + mode: 'selected', + event: { pull_request: { draft: true } } + }) + expect(plan.executionFiles).toEqual(files) + expect(plan.selectionAvailable).toBe(false) + expect(plan.shards).toHaveLength(FULL_SHARD_COUNT) + }) + + it('reads lazy graph evidence for source changes', () => { + expect(selectUnitFiles(files, ['src/leaf.ts'], () => graph)).toEqual( + selectUnitFiles(files, ['src/leaf.ts'], graph) + ) + }) + it('keeps full coverage by default and on every non-draft commit', () => { const base = { files, changed: ['src/leaf.ts'], graph, timings: {} } for (const event of [ diff --git a/config/scripts/create-draft-release.mjs b/config/scripts/create-draft-release.mjs index a25bced199b1..d69f4cdb8cc9 100644 --- a/config/scripts/create-draft-release.mjs +++ b/config/scripts/create-draft-release.mjs @@ -4,7 +4,7 @@ import { execFileSync } from 'node:child_process' import { pathToFileURL } from 'node:url' const API_VERSION = '2022-11-28' -const MAX_RELEASE_BODY_LENGTH = 120_000 +export const MAX_RELEASE_BODY_LENGTH = 120_000 const TRUNCATION_NOTICE = '\n\n---\nRelease notes were truncated because GitHub release bodies are limited to 125,000 characters.' const DESKTOP_RELEASE_TAG_PATTERN = /^v(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/ diff --git a/config/scripts/daemon-protocol-facts.mjs b/config/scripts/daemon-protocol-facts.mjs new file mode 100644 index 000000000000..c63d2c084d2f --- /dev/null +++ b/config/scripts/daemon-protocol-facts.mjs @@ -0,0 +1,99 @@ +// Reads a build's daemon protocol version and the older versions it can still attach to. +// Parsed from source text so a release tag and a candidate tree are read the same way. +// Usage: node daemon-protocol-facts.mjs [output.json] +import { readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +export const DAEMON_PROTOCOL_SOURCE_PATH = 'src/main/daemon/daemon-protocol-version.ts' + +const CURRENT_DECLARATION = /^export const PROTOCOL_VERSION\b[^=\n]*=\s*([^\n;]*)/gmu +const PREVIOUS_DECLARATION = + /^export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS\b[^=\n]*=\s*\[([^\]]*)\]/gmu + +function singleMatch(source, pattern, name, label) { + const matches = [...source.matchAll(pattern)] + if (matches.length !== 1) { + throw new Error( + `${label}: expected exactly one \`export const ${name}\` declaration, found ${matches.length}` + ) + } + return matches[0][1] +} + +function parseProtocolInteger(text, name, label) { + const trimmed = text.trim() + // Why: a reference like `= NEXT_VERSION` must fail, not coerce to NaN or 0. + if (!/^\d+$/u.test(trimmed)) { + throw new Error(`${label}: ${name} must be an integer literal, got \`${trimmed}\``) + } + const value = Number(trimmed) + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error(`${label}: ${name} must be a positive integer, got \`${trimmed}\``) + } + return value +} + +/** + * @param {string} source + * @param {string} [label] names the source in errors + * @returns {{ protocolVersion: number, previousProtocolVersions: number[] }} + */ +export function parseDaemonProtocolFacts(source, label = DAEMON_PROTOCOL_SOURCE_PATH) { + const protocolVersion = parseProtocolInteger( + singleMatch(source, CURRENT_DECLARATION, 'PROTOCOL_VERSION', label), + 'PROTOCOL_VERSION', + label + ) + const previousProtocolVersions = singleMatch( + source, + PREVIOUS_DECLARATION, + 'PREVIOUS_DAEMON_PROTOCOL_VERSIONS', + label + ) + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => parseProtocolInteger(entry, 'PREVIOUS_DAEMON_PROTOCOL_VERSIONS entry', label)) + if (protocolVersion > 1 && previousProtocolVersions.length === 0) { + throw new Error(`${label}: PREVIOUS_DAEMON_PROTOCOL_VERSIONS parsed as empty`) + } + const outOfRange = previousProtocolVersions.filter((version) => version >= protocolVersion) + if (outOfRange.length > 0) { + throw new Error( + `${label}: PREVIOUS_DAEMON_PROTOCOL_VERSIONS lists ${outOfRange.join(', ')}, not below PROTOCOL_VERSION ${protocolVersion}` + ) + } + return { protocolVersion, previousProtocolVersions } +} + +/** Whether `reader` can route sessions owned by a daemon speaking `owner`'s protocol. */ +export function canAttach(reader, owner) { + return ( + reader.protocolVersion === owner.protocolVersion || + reader.previousProtocolVersions.includes(owner.protocolVersion) + ) +} + +/** What any candidate must declare for sessions to cross in each direction with `release`. */ +export function crossingRequirements(release) { + const accepted = [...release.previousProtocolVersions, release.protocolVersion] + return { + upgrade: `candidate speaks ${release.protocolVersion} or lists ${release.protocolVersion} as previous`, + rollback: `candidate speaks one of ${Math.min(...accepted)}..${Math.max(...accepted)} (the release's own or previous list)`, + // Only an owner the release already speaks can survive a rollback, so a newer-protocol candidate cannot. + bothDirections: `candidate speaks ${release.protocolVersion}, or speaks an older release-listed version and lists ${release.protocolVersion} as previous` + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const [input, output] = process.argv.slice(2) + if (!input) { + throw new Error('usage: daemon-protocol-facts.mjs [output.json]') + } + const facts = parseDaemonProtocolFacts(readFileSync(input, 'utf8'), input) + if (output) { + writeFileSync(output, `${JSON.stringify(facts)}\n`) + } + console.log(JSON.stringify(facts)) +} diff --git a/config/scripts/daemon-protocol-facts.test.mjs b/config/scripts/daemon-protocol-facts.test.mjs new file mode 100644 index 000000000000..4288f3179ee6 --- /dev/null +++ b/config/scripts/daemon-protocol-facts.test.mjs @@ -0,0 +1,90 @@ +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + DAEMON_PROTOCOL_SOURCE_PATH, + canAttach, + crossingRequirements, + parseDaemonProtocolFacts +} from './daemon-protocol-facts.mjs' + +const projectDir = resolve(import.meta.dirname, '../..') + +function source(current, previous) { + return [ + `export const PROTOCOL_VERSION = ${current}`, + 'export const OTHER_DAEMON_PROTOCOL_VERSION = 5', + `export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [${previous}] as const` + ].join('\n') +} + +const facts = (protocolVersion, previousProtocolVersions) => ({ + protocolVersion, + previousProtocolVersions +}) + +describe('parseDaemonProtocolFacts', () => { + it('reads the working tree declarations and keeps the append-only range', () => { + const parsed = parseDaemonProtocolFacts( + readFileSync(join(projectDir, DAEMON_PROTOCOL_SOURCE_PATH), 'utf8') + ) + expect(parsed.protocolVersion).toBeGreaterThan(1) + expect(parsed.previousProtocolVersions).toEqual( + Array.from({ length: parsed.protocolVersion - 1 }, (_, index) => index + 1) + ) + }) + + it('parses multi-line lists and type annotations', () => { + const text = [ + '// PROTOCOL_VERSION = 99 in a comment is ignored', + 'export const PROTOCOL_VERSION: number = 4', + 'export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS: readonly number[] = [', + ' 1, 2,', + ' 3,', + ']' + ].join('\n') + expect(parseDaemonProtocolFacts(text)).toEqual(facts(4, [1, 2, 3])) + }) + + it.each([ + ['a missing PROTOCOL_VERSION', 'export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1]'], + ['a missing previous list', 'export const PROTOCOL_VERSION = 2'], + ['a non-literal PROTOCOL_VERSION', source('NEXT_VERSION', '1')], + ['a spread in the previous list', source(3, '...LEGACY, 2')], + ['a comment in the previous list', source(3, '1, // legacy\n 2')], + ['an empty previous list past v1', source(3, '')], + ['a previous version at or above current', source(3, '1, 2, 3')], + ['a duplicate declaration', `${source(3, '1, 2')}\nexport const PROTOCOL_VERSION = 4`], + [ + 'a list moved behind a constant', + 'export const PROTOCOL_VERSION = 3\nexport const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = LEGACY' + ] + ])('fails loudly on %s', (_name, text) => { + expect(() => parseDaemonProtocolFacts(text, 'fixture.ts')).toThrow(/fixture\.ts/) + }) +}) + +describe('protocol crossing', () => { + const release = facts(37, [36, 35]) + + it('lets a same-version or listing candidate adopt the release daemon', () => { + expect(canAttach(facts(37, [36, 35]), release)).toBe(true) + expect(canAttach(facts(38, [37, 36, 35]), release)).toBe(true) + }) + + it('rejects a candidate that dropped the release version', () => { + expect(canAttach(facts(38, [36, 35]), release)).toBe(false) + }) + + it('cannot roll back past a protocol bump', () => { + expect(canAttach(release, facts(38, [37, 36, 35]))).toBe(false) + expect(canAttach(release, facts(36, [35]))).toBe(true) + }) + + it('describes the requirements in both directions', () => { + expect(crossingRequirements(release)).toMatchObject({ + upgrade: 'candidate speaks 37 or lists 37 as previous', + rollback: expect.stringContaining('35..37') + }) + }) +}) diff --git a/config/scripts/daily-build-version.test.mjs b/config/scripts/daily-build-version.test.mjs index 422d570a8789..b08844b56bef 100644 --- a/config/scripts/daily-build-version.test.mjs +++ b/config/scripts/daily-build-version.test.mjs @@ -57,11 +57,6 @@ describe('formatDailyReleaseName', () => { expect(name('2026-07-28T17:15:00Z')).toBe('1.4.163 • 01 • Jul 28, 10:15AM • e698241') }) - it('pads to two digits and grows past them', () => { - expect(name('2026-07-28T17:15:00Z', 9)).toContain(' • 09 • ') - expect(name('2026-07-28T17:15:00Z', 42)).toContain(' • 42 • ') - }) - it('rejects a build number that is not a positive integer', () => { expect(() => name('2026-07-28T17:15:00Z', 0)).toThrow(/positive integer/) expect(() => name('2026-07-28T17:15:00Z', -1)).toThrow(/positive integer/) diff --git a/config/scripts/electron-builder-runtime-resources.test.mjs b/config/scripts/electron-builder-runtime-resources.test.mjs index f74c1c09e010..2d2ecef4995f 100644 --- a/config/scripts/electron-builder-runtime-resources.test.mjs +++ b/config/scripts/electron-builder-runtime-resources.test.mjs @@ -527,8 +527,6 @@ describe('packaged runtime resources', () => { ) }) -const BUN_RUNTIME_BUILTINS = new Set(['bun:ffi', 'bun:sqlite']) - // Why source-anchored: the bundler renames a createRequire()'d require, so // verifyPackagedMainRuntimeDeps' `require("x")` scan cannot see these specifiers — packaging // stays green while the packaged app throws MODULE_NOT_FOUND the first time the path runs. @@ -557,7 +555,7 @@ function collectLazyRequireSpecifiers(directory, found = new Map()) { continue } for (const match of source.matchAll(/\brequire[A-Za-z0-9_]*\(\s*'([^']+)'\s*\)/g)) { - if (!BUN_RUNTIME_BUILTINS.has(match[1]) && isPackagedExternalSpecifier(match[1])) { + if (isPackagedExternalSpecifier(match[1])) { found.set(match[1], relative(projectRoot, entryPath).replaceAll('\\', '/')) } } @@ -574,21 +572,18 @@ function packagedResourceDestinations(platform) { } describe('lazily required packages reach Resources/node_modules', () => { - it('excludes Bun runtime builtins while retaining ordinary lazy dependencies', async () => { - const sourceDir = await mkdtemp(join(tmpdir(), 'orca-lazy-bun-builtins-')) + it('excludes runtime builtins while retaining ordinary lazy dependencies', async () => { + const sourceDir = await mkdtemp(join(tmpdir(), 'orca-lazy-builtins-')) try { await writeFile( join(sourceDir, 'runtime.ts'), [ 'const requireFromMain = createRequire(import.meta.url)', "requireFromMain('node:fs')", - "requireFromMain('bun:ffi')", - "requireFromMain('bun:sqlite')", - "requireFromMain('zod')", - "requireFromMain('bun-sqlite')" + "requireFromMain('zod')" ].join('\n') ) - expect([...collectLazyRequireSpecifiers(sourceDir).keys()]).toEqual(['zod', 'bun-sqlite']) + expect([...collectLazyRequireSpecifiers(sourceDir).keys()]).toEqual(['zod']) } finally { await removeTree(sourceDir) } diff --git a/config/scripts/mobile-web-app-held-press-probe-routes.mjs b/config/scripts/mobile-web-app-held-press-probe-routes.mjs new file mode 100644 index 000000000000..7537ec840cfe --- /dev/null +++ b/config/scripts/mobile-web-app-held-press-probe-routes.mjs @@ -0,0 +1,271 @@ +/** + * Scratch routes for the held-press check: the surfaces whose press must outlive Android WebView's + * long-press. Each mounts the shipped component with stubbed effects; what is under test is the + * press responder, so handlers only count. + */ + +/** RN Web renders `nativeID` as the DOM `id`; the check touches each mic through it. */ +export const TERMINAL_MIC_ID = 'hold-probe-terminal-mic' +export const CHAT_MIC_ID = 'hold-probe-chat-mic' + +export function holdDictationProbeRouteSource({ terminalActionsModule, chatComposerModule }) { + return `import { useRef, useState } from 'react' +import { Text, View } from 'react-native' +import { MobileTerminalInputActions } from ${JSON.stringify(terminalActionsModule)} +import { MobileNativeChatComposer } from ${JSON.stringify(chatComposerModule)} + +const noop = () => {} + +export default function HoldDictationProbeRoute() { + const [terminalHeld, setTerminalHeld] = useState(false) + const [chatHeld, setChatHeld] = useState(false) + const pressOutsRef = useRef({ terminal: 0, chat: 0 }) + globalThis.__orcaHeldPressProbe = { pressOuts: () => ({ ...pressOutsRef.current }) } + return ( + + Model + + setTerminalHeld(true)} + onDictationPressOut={() => { + pressOutsRef.current.terminal += 1 + setTerminalHeld(false) + }} + onDictationCancel={noop} + /> + + + Promise.resolve(false)} + sendSurfaceId="hold-probe" + getSendCompletionGeneration={() => 0} + getComposerEditGeneration={() => 0} + onMicPress={noop} + micActive={chatHeld} + dictationMode="hold" + onMicPressIn={() => setChatHeld(true)} + onMicPressOut={() => { + pressOutsRef.current.chat += 1 + setChatHeld(false) + }} + /> + + + ) +} +` +} + +export const REPEAT_KEY_LABEL = 'Arrow Up' +export const TAP_KEY_LABEL = 'Escape' + +/** + * The session's command dock with a stub controller, in buffered mode. The repeat cadence is + * `use-mobile-session-accessory-selection.ts`'s (send on press-in, repeat after 400 ms, then every + * 45 ms, stop on press-out), restated here because that hook is bound to the whole session. + */ +export function commandDockProbeRouteSource({ commandDockModule, keyDefinitionsModule }) { + return `import { useRef } from 'react' +import { MobileSessionCommandDock } from ${JSON.stringify(commandDockModule)} +import { TERMINAL_ACCESSORY_KEY_DEFINITIONS } from ${JSON.stringify(keyDefinitionsModule)} + +const noop = () => {} +const KEYS = TERMINAL_ACCESSORY_KEY_DEFINITIONS.filter((key) => ['arrowUp', 'escape'].includes(key.id)) +const IDLE = { isStarting: false, isRecording: false, isProcessing: false } + +export default function CommandDockProbeRoute() { + const sentRef = useRef([]) + const timersRef = useRef({ timeout: null, interval: null }) + const stop = () => { + clearTimeout(timersRef.current.timeout) + clearInterval(timersRef.current.interval) + } + const send = (input) => { + sentRef.current.push(input.bytes) + return Promise.resolve() + } + const start = (input) => { + stop() + timersRef.current.timeout = setTimeout(() => { + timersRef.current.interval = setInterval(() => void send(input), 45) + }, 400) + } + globalThis.__orcaHeldPressProbe = { sent: () => [...sentRef.current] } + const controller = { + insets: { top: 0, bottom: 0, left: 0, right: 0 }, + bufferedTerminalDraftState: { input: '', setInput: noop }, + autocompleteEnabled: false, + liveInputCapture: '', + activeHandle: 'held-press-terminal', + customKeys: [], + setShowCustomKeyModal: noop, + setDeleteKeyTarget: noop, + visibleBuiltInAccessoryKeys: KEYS, + terminalModes: new Map(), + canPaste: false, + dictationMode: 'toggle', + bindCommandField: noop, + handleLiveInputChange: noop, + handleLiveInputKeyPress: noop, + bindLiveInputField: noop, + submitLiveInput: noop, + canSend: true, + canCompose: true, + liveInputEnabled: false, + focusLiveInput: noop, + showNativeChat: false, + dictation: IDLE, + cancelDictation: noop, + handleDictationToggle: noop, + handleDictationPressIn: noop, + handleDictationPressOut: noop, + toggleDisplayMode: noop, + handleSend: () => Promise.resolve(), + handleAccessoryKey: send, + dismissSoftwareKeyboard: noop, + toggleLiveInput: noop, + stopAccessoryRepeat: stop, + startAccessoryRepeat: start, + handlePaste: () => Promise.resolve(), + isAttaching: false, + attachImage: () => Promise.resolve(), + activeMarkdownTab: null, + activeFileTab: null, + activeBrowserTab: null, + keyboardLift: 0 + } + return +} +` +} + +export const BROWSER_VIEWPORT_ID = 'held-press-browser-viewport' + +/** + * The browser pane's view with its real interactions hook, which owns the 550 ms long-press + * right-click. The stub client records each request, so a right-click is read where the host would + * see it, not only from the hook's "Right click" toast. + */ +export function browserPaneProbeRouteSource({ + paneViewModule, + interactionsModule, + geometryModule +}) { + return `import { useRef, useState } from 'react' +import { View } from 'react-native' +import { MobileBrowserPaneView } from ${JSON.stringify(paneViewModule)} +import { useMobileBrowserInteractions } from ${JSON.stringify(interactionsModule)} +import { computeBrowserFrameGeometry } from ${JSON.stringify(geometryModule)} + +const noop = () => {} +const LAYOUT = { width: 390, height: 500 } +const LAYER = { attachView: noop, attachImage: noop, onLoad: noop, onError: noop } +const TAB = { + type: 'browser', + id: 'held-press-tab', + title: 'Example', + browserWorkspaceId: 'held-press-workspace', + browserPageId: 'held-press-page', + url: 'https://example.com/', + loading: false, + canGoBack: false, + canGoForward: false +} + +export default function BrowserPaneProbeRoute() { + const toastsRef = useRef([]) + const requestsRef = useRef([]) + const clientRef = useRef({ + sendRequest: (method, params) => { + requestsRef.current.push({ method, params }) + return Promise.resolve({ ok: true, id: String(requestsRef.current.length), result: {} }) + } + }) + const layoutRef = useRef(LAYOUT) + const longPressTimerRef = useRef(null) + const zoom = { scale: 1, offsetX: 0, offsetY: 0 } + const [dialog, setDialog] = useState(null) + const dialogRef = useRef(null) + dialogRef.current = dialog + const frameGeometry = computeBrowserFrameGeometry(LAYOUT, null) + const interactions = useMobileBrowserInteractions({ + clearLongPressTimer: () => clearTimeout(longPressTimerRef.current), + client: clientRef.current, + dialogRef, + frameGeometry, + frameMetadataRef: { current: null }, + keyboardValue: '', + layoutRef, + longPressTimerRef, + onToast: (message) => toastsRef.current.push(message), + pageParams: () => ({ worktree: 'held-press-worktree', page: TAB.browserPageId }), + panRef: useRef(null), + pinchRef: useRef(null), + pointerModifiers: [], + sendBrowserRequest: () => Promise.resolve(null), + scrollingRef: useRef(false), + startPointRef: useRef(null), + setDialog, + setError: noop, + setKeyboardValue: noop, + setPointerModifiers: noop, + setZoom: noop, + zoomRef: { current: zoom } + }) + globalThis.__orcaHeldPressProbe = { + toasts: () => [...toastsRef.current], + requests: () => [...requestsRef.current] + } + return ( + + Promise.resolve()} + panResponder={interactions.panResponder} + pointerModifiers={[]} + reloadPage={noop} + renderedFrameSource={null} + selectBrowserViewMode={noop} + sendDialogCommand={interactions.sendDialogCommand} + sendKeyboardText={() => Promise.resolve()} + sendKeypress={() => Promise.resolve()} + setAddressFocused={noop} + setAddressValue={noop} + setKeyboardValue={noop} + setLayout={noop} + setRootViewRef={noop} + tab={TAB} + togglePointerModifier={noop} + zoom={zoom} + /> + + ) +} +` +} diff --git a/config/scripts/mobile-web-app-held-press-render.test.mjs b/config/scripts/mobile-web-app-held-press-render.test.mjs new file mode 100644 index 000000000000..279378f70637 --- /dev/null +++ b/config/scripts/mobile-web-app-held-press-render.test.mjs @@ -0,0 +1,326 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { chromium } from 'playwright-core' +import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs' +import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' +import { + BROWSER_VIEWPORT_ID, + CHAT_MIC_ID, + REPEAT_KEY_LABEL, + TAP_KEY_LABEL, + TERMINAL_MIC_ID, + browserPaneProbeRouteSource, + commandDockProbeRouteSource, + holdDictationProbeRouteSource +} from './mobile-web-app-held-press-probe-routes.mjs' +import { MOBILE_WEB_APP_ROUTE_ROOT } from './mobile-web-app-route-manifest.mjs' +import { + createBundleServer, + installShellDouble, + projectDir, + readBridgeFaultGrant, + readBridgeProtocolVersion, + readShellCsp +} from './mobile-web-app-render-harness.mjs' +import { LAYOUT_SOURCE } from './mobile-web-app-terminal-probe-route.mjs' + +/** + * Held presses on the page. Held ~500 ms, Android WebView turns a touch into a long-press: it fires + * `contextmenu`, and where text can be selected it starts a selection and then cancels the touch. + * react-native-web ends a press on all three unless the press refuses `contextmenu` (a Pressable + * with `onLongPress`), and the page's native-parity style leaves no text to select. Traced on an + * emulator; headless Chromium generates no long-press from CDP touches, so `holdLikeAndroidWebView` + * plays it. + */ + +const ROUTES = { + mic: `/${MOBILE_WEB_APP_ROUTE_ROOT}/hold-dictation-probe`, + keys: `/${MOBILE_WEB_APP_ROUTE_ROOT}/command-dock-probe`, + browser: `/${MOBILE_WEB_APP_ROUTE_ROOT}/browser-pane-probe` +} +/** When Android WebView's long-press lands; a selection's cancel follows ~20-50 ms later. */ +const LONG_PRESS_MS = 500 +const bundles = mobileWebAppDependenciesPresent() +const describeRender = bundles ? describe : describe.skip + +let browser = null +let origin = null +let scratch = null +let server = null +let bridgeVersion = null +let faultGrant = null + +beforeAll(async () => { + if (!bundles) { + return + } + const sessionDir = join(projectDir, 'mobile', 'src', 'session') + const browserDir = join(projectDir, 'mobile', 'src', 'browser') + const terminalDir = join(projectDir, 'mobile', 'src', 'terminal') + const cspHeader = await readShellCsp() + bridgeVersion = await readBridgeProtocolVersion() + faultGrant = await readBridgeFaultGrant() + scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-held-press-')) + const appDir = join(scratch, 'app') + const routeDir = join(appDir, MOBILE_WEB_APP_ROUTE_ROOT) + await mkdir(routeDir, { recursive: true }) + await writeFile(join(routeDir, '_layout.tsx'), LAYOUT_SOURCE) + await writeFile( + join(routeDir, 'hold-dictation-probe.tsx'), + holdDictationProbeRouteSource({ + terminalActionsModule: join(sessionDir, 'MobileTerminalInputActions'), + chatComposerModule: join(sessionDir, 'MobileNativeChatComposer') + }) + ) + await writeFile( + join(routeDir, 'command-dock-probe.tsx'), + commandDockProbeRouteSource({ + commandDockModule: join(sessionDir, 'MobileSessionCommandDock'), + keyDefinitionsModule: join(terminalDir, 'terminal-key-definitions') + }) + ) + await writeFile( + join(routeDir, 'browser-pane-probe.tsx'), + browserPaneProbeRouteSource({ + paneViewModule: join(browserDir, 'MobileBrowserPaneView'), + interactionsModule: join(browserDir, 'use-mobile-browser-interactions'), + geometryModule: join(browserDir, 'browser-touch-geometry') + }) + ) + const built = await buildMobileWebAppBundle({ + appDir, + outDir: join(scratch, 'bundle'), + pageRoutes: Object.values(ROUTES).map((pathname) => ({ pathname, grants: [] })) + }) + const served = await createBundleServer({ outDir: built.outDir, cspHeader }) + server = served.server + origin = served.origin + const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER + browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) }) +}, 600_000) + +afterAll(async () => { + await browser?.close() + server?.close() + if (scratch) { + await rm(scratch, { recursive: true, force: true }) + } +}) + +async function openProbe(pathname) { + const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true }) + await page.addInitScript(installShellDouble, { + version: bridgeVersion, + sessionId: 'held-press-session', + buildId: 'held-press-build', + route: { pathname, params: {} }, + host: { id: 'held-host', name: 'Held Host', endpoint: 'ws://held', lastConnected: 1 }, + storage: {}, + faultGrant, + grants: [faultGrant], + pageRoutes: Object.values(ROUTES), + replies: {} + }) + const errors = [] + page.on('pageerror', (error) => errors.push(`${error.name}: ${error.message}`)) + await page.goto(`${origin}/`, { waitUntil: 'load' }) + await page.waitForFunction( + () => + globalThis.__orcaHeldPressProbe !== undefined || + (globalThis.__orcaRenderCheckFaults ?? []).length > 0, + { timeout: 60_000, polling: 100 } + ) + expect(await page.evaluate(() => globalThis.__orcaRenderCheckFaults ?? [])).toEqual([]) + return { errors, page } +} + +/** + * Touch-holds `selector` for `holdMs`, reads `read()` mid-hold, then lifts. At 500 ms it plays + * Android WebView's long-press: `contextmenu`, then — only where the touch could select text — + * `selectionchange` and `touchcancel`, which is what the device did while page text was selectable. + */ +async function holdLikeAndroidWebView(page, selector, { holdMs, read }) { + // A handle, not a locator: a label the selector matches can change as soon as the press lands. + const target = await page.waitForSelector(selector) + const box = await target.boundingBox() + const point = { x: box.x + box.width / 2, y: box.y + box.height / 2 } + const input = await page.context().newCDPSession(page) + await input.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [point] }) + await page.waitForTimeout(LONG_PRESS_MS) + const selectable = await target.evaluate((node, at) => { + node.dispatchEvent( + new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: at.x, + clientY: at.y + }) + ) + // The nearest explicit user-select up the tree decides, as Blink resolves `auto`. + for (let element = node; element; element = element.parentElement) { + const value = getComputedStyle(element).userSelect + if (value && value !== 'auto') { + return value !== 'none' + } + } + return true + }, point) + if (selectable) { + await page.evaluate(() => document.dispatchEvent(new Event('selectionchange'))) + await input.send('Input.dispatchTouchEvent', { type: 'touchCancel', touchPoints: [] }) + } + await page.waitForTimeout(holdMs - LONG_PRESS_MS) + const midHold = { selectable, ...(await read(target)) } + if (!selectable) { + await input.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) + } + await page.waitForTimeout(300) + return { midHold, afterRelease: await read(target) } +} + +const pressOuts = (page) => page.evaluate(() => globalThis.__orcaHeldPressProbe.pressOuts()) + +describeRender( + 'held presses on the page', + () => { + describe('hold-to-dictate', () => { + it('keeps the terminal mic held until the finger lifts', async () => { + const { errors, page } = await openProbe(ROUTES.mic) + const { midHold, afterRelease } = await holdLikeAndroidWebView( + page, + `#${TERMINAL_MIC_ID} [aria-label="Start voice dictation"]`, + { + holdMs: 1500, + read: async (mic) => ({ + label: await mic.getAttribute('aria-label'), + pressOuts: await pressOuts(page) + }) + } + ) + expect(midHold).toEqual({ + selectable: false, + label: 'Stop voice dictation', + pressOuts: { terminal: 0, chat: 0 } + }) + expect(afterRelease.pressOuts).toEqual({ terminal: 1, chat: 0 }) + expect(errors).toEqual([]) + await page.close() + }, 300_000) + + it('keeps the chat mic held until the finger lifts', async () => { + const { errors, page } = await openProbe(ROUTES.mic) + const { midHold, afterRelease } = await holdLikeAndroidWebView( + page, + `#${CHAT_MIC_ID} [aria-label="Dictate"]`, + { + holdMs: 1500, + read: async (mic) => ({ + label: await mic.getAttribute('aria-label'), + pressOuts: await pressOuts(page) + }) + } + ) + expect(midHold).toEqual({ + selectable: false, + label: 'Stop dictation', + pressOuts: { terminal: 0, chat: 0 } + }) + // The icon under the finger swaps on press, so the release has to reach the Pressable. + expect(afterRelease.pressOuts).toEqual({ terminal: 0, chat: 1 }) + expect(errors).toEqual([]) + await page.close() + }, 300_000) + }) + + describe('the key bar', () => { + const sentCount = async (page) => + (await page.evaluate(() => globalThis.__orcaHeldPressProbe.sent())).length + + it('keeps repeating a held arrow key until the finger lifts', async () => { + const { errors, page } = await openProbe(ROUTES.keys) + const { midHold, afterRelease } = await holdLikeAndroidWebView( + page, + `[aria-label="${REPEAT_KEY_LABEL}"]`, + { holdMs: 1500, read: async () => ({ sent: await sentCount(page) }) } + ) + // One send on press-in, then every 45 ms from 400 ms: ~25 by 1.5 s, against 1 when the + // long-press ends the press before the first repeat. + expect(midHold.sent).toBeGreaterThan(20) + expect(midHold.selectable).toBe(false) + // Nothing more once the finger is up. + expect(afterRelease.sent - midHold.sent).toBeLessThan(3) + expect(errors).toEqual([]) + await page.close() + }, 300_000) + + it('selects nothing on a long-press of a tap key', async () => { + const { errors, page } = await openProbe(ROUTES.keys) + const { midHold } = await holdLikeAndroidWebView(page, `[aria-label="${TAP_KEY_LABEL}"]`, { + holdMs: 1000, + read: () => page.evaluate(() => ({ selection: window.getSelection()?.toString() ?? '' })) + }) + expect(midHold).toEqual({ selectable: false, selection: '' }) + expect(errors).toEqual([]) + await page.close() + }, 300_000) + + it('sends a tapped key once', async () => { + const { errors, page } = await openProbe(ROUTES.keys) + const key = await page.waitForSelector(`[aria-label="${TAP_KEY_LABEL}"]`) + await key.tap() + await page.waitForFunction(() => globalThis.__orcaHeldPressProbe.sent().length === 1) + expect(await page.evaluate(() => globalThis.__orcaHeldPressProbe.sent())).toEqual(['\x1b']) + expect(errors).toEqual([]) + await page.close() + }, 300_000) + }) + + describe('the browser pane', () => { + const toasts = (page) => page.evaluate(() => globalThis.__orcaHeldPressProbe.toasts()) + const clicks = (page) => + page.evaluate(() => + globalThis.__orcaHeldPressProbe + .requests() + .filter((request) => request.method === 'browser.mouseClick') + .map((request) => ({ button: request.params.button, page: request.params.page })) + ) + + it('right-clicks on a 1 s hold', async () => { + const { errors, page } = await openProbe(ROUTES.browser) + const { midHold, afterRelease } = await holdLikeAndroidWebView( + page, + `#${BROWSER_VIEWPORT_ID}`, + { + holdMs: 1000, + read: async () => ({ toasts: await toasts(page), clicks: await clicks(page) }) + } + ) + // The hook's timer is 550 ms, after the WebView's long-press would have ended the press. + const rightClick = { button: 'right', page: 'held-press-page' } + expect(midHold).toEqual({ + selectable: false, + toasts: ['Right click'], + clicks: [rightClick] + }) + // The release after a right-click sends no left click. + expect(afterRelease.clicks).toEqual([rightClick]) + expect(errors).toEqual([]) + await page.close() + }, 300_000) + + it('left-clicks once on a tap', async () => { + const { errors, page } = await openProbe(ROUTES.browser) + const viewport = await page.waitForSelector(`#${BROWSER_VIEWPORT_ID}`) + await viewport.tap() + await page.waitForFunction(() => globalThis.__orcaHeldPressProbe.requests().length > 0) + expect(await clicks(page)).toEqual([{ button: 'left', page: 'held-press-page' }]) + expect(await toasts(page)).toEqual([]) + expect(errors).toEqual([]) + await page.close() + }, 300_000) + }) + }, + 900_000 +) diff --git a/config/scripts/mobile-web-app-native-parity-render.test.mjs b/config/scripts/mobile-web-app-native-parity-render.test.mjs index ff91f9b598bc..d20746766131 100644 --- a/config/scripts/mobile-web-app-native-parity-render.test.mjs +++ b/config/scripts/mobile-web-app-native-parity-render.test.mjs @@ -47,7 +47,7 @@ const ENGINES = [ const PAGE_ENTRY = ` import { createElement as h } from 'react' import { createRoot } from 'react-dom/client' -const { StyleSheet, TextInput, View } = require('react-native') +const { Modal, StyleSheet, Text, TextInput, View } = require('react-native') const styles = StyleSheet.create({ separatorBox: { paddingTop: 10.1, width: 12, backgroundColor: '#ffffff' }, separator: { height: StyleSheet.hairlineWidth, backgroundColor: '#000000' }, @@ -55,7 +55,7 @@ const styles = StyleSheet.create({ input: { height: 40 } }) createRoot(document.getElementById('root')).render( - h(View, null, h(View, { testID: 'separator-box', style: styles.separatorBox }, h(View, { style: styles.separator })), h(View, { testID: 'hairline', style: styles.hairline }), h(TextInput, { testID: 'input', style: styles.input })) + h(View, null, h(View, { testID: 'separator-box', style: styles.separatorBox }, h(View, { style: styles.separator })), h(View, { testID: 'hairline', style: styles.hairline }), h(TextInput, { testID: 'input', style: styles.input }), h(Text, { testID: 'plain-text' }, 'row'), h(Text, { testID: 'selectable-text', selectable: true }, 'message'), location.hash === '#modal' ? h(Modal, { visible: true, transparent: true }, h(View, null, h(Text, { testID: 'modal-text' }, 'sheet row'), h(TextInput, { testID: 'modal-input', style: styles.input }))) : null) ) ` @@ -111,9 +111,9 @@ afterAll(async () => { } }) -async function openPage(engine) { +async function openPage(engine, hash = '') { const page = await browsers.get(engine.name).newPage(engine.pageOptions) - await page.goto(`${origin}/`, { waitUntil: 'domcontentloaded' }) + await page.goto(`${origin}/${hash}`, { waitUntil: 'domcontentloaded' }) await page.waitForSelector('[data-testid="input"]') return page } @@ -168,4 +168,81 @@ describeParity.each(ENGINES)('the page against native, at a phone density, in $n await page.close() } }) + + it('leaves only selectable text and editable fields selectable, as native does', async () => { + // The iOS shell keeps WebKit text interaction on so fields take text, and a hold on plain text + // would otherwise raise WebKit's selection over the page's own long press. + const page = await openPage(engine) + try { + await page.type('[data-testid="input"]', 'typed') + const measured = await page.evaluate(() => { + const select = (id) => getComputedStyle(document.querySelector(`[data-testid="${id}"]`)) + const read = (id) => select(id).webkitUserSelect || select(id).userSelect + return { + plain: read('plain-text'), + selectable: read('selectable-text'), + input: read('input'), + value: document.querySelector('[data-testid="input"]').value + } + }) + expect(measured).toEqual({ plain: 'none', selectable: 'text', input: 'text', value: 'typed' }) + } finally { + await page.close() + } + }) + + it("selects through the browser's own gesture only what native lets the user select", async () => { + // A double-click, not a Range: a script can select text a user cannot. + const page = await openPage(engine) + try { + const selectedBy = async (testId) => { + await page.evaluate(() => window.getSelection()?.removeAllRanges()) + await page.dblclick(`[data-testid="${testId}"]`) + return page.evaluate(() => window.getSelection()?.toString() ?? '') + } + expect(await selectedBy('plain-text')).toBe('') + // react-native-web's Text/index.js:115 adds `styles.selectable` (`userSelect: 'text'`). + expect(await selectedBy('selectable-text')).not.toBe('') + + await page.type('[data-testid="input"]', 'hello world') + await page.dblclick('[data-testid="input"]') + const selectedInField = await page.evaluate(() => { + const input = document.querySelector('[data-testid="input"]') + return input.selectionEnd - input.selectionStart + }) + expect(selectedInField).toBeGreaterThan(0) + } finally { + await page.close() + } + }) + + it('applies the same to text a modal portals outside #root, and keeps its fields selectable', async () => { + // react-native-web's Modal appends to document.body, so a rule on #root would miss sheets. + // The fields' callout exemption goes unmeasured: desktop WebKit lacks the property. + const page = await openPage(engine, '#modal') + try { + await page.waitForSelector('[data-testid="modal-text"]') + const measured = await page.evaluate(() => { + const text = document.querySelector('[data-testid="modal-text"]') + const input = document.querySelector('[data-testid="modal-input"]') + const read = (node) => + getComputedStyle(node).webkitUserSelect || getComputedStyle(node).userSelect + return { + outsideRoot: !document.getElementById('root').contains(text), + text: read(text), + input: read(input) + } + }) + expect(measured).toEqual({ + outsideRoot: true, + text: 'none', + input: 'text' + }) + await page.evaluate(() => window.getSelection()?.removeAllRanges()) + await page.dblclick('[data-testid="modal-text"]') + expect(await page.evaluate(() => window.getSelection()?.toString() ?? '')).toBe('') + } finally { + await page.close() + } + }) }) diff --git a/config/scripts/mobile-web-app-rich-markdown-render.test.mjs b/config/scripts/mobile-web-app-rich-markdown-render.test.mjs index d1133bce1437..45b37eeba7c4 100644 --- a/config/scripts/mobile-web-app-rich-markdown-render.test.mjs +++ b/config/scripts/mobile-web-app-rich-markdown-render.test.mjs @@ -20,7 +20,11 @@ import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import * as esbuild from 'esbuild' import { chromium, webkit } from 'playwright-core' -import { MOBILE_WEB_APP_ROOT_RESET, lucideBarrelPlugin } from './build-mobile-web-app-bundle.mjs' +import { + MOBILE_WEB_APP_NATIVE_PARITY_STYLE, + MOBILE_WEB_APP_ROOT_RESET, + lucideBarrelPlugin +} from './build-mobile-web-app-bundle.mjs' import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' import { textInputFontSizeFloor } from './mobile-web-app-text-input-font-size-seam.mjs' import { @@ -270,7 +274,8 @@ beforeAll(async () => { join(outDir, 'index.html'), // The root reset the shipped document carries: every box below the mount is `flex: 1`, so // without a definite height on all three the editor measures 0 and paints nothing. - `${MOBILE_WEB_APP_ROOT_RESET}` + + `${MOBILE_WEB_APP_ROOT_RESET}` + + `${MOBILE_WEB_APP_NATIVE_PARITY_STYLE}` + '
' + '' ) @@ -512,6 +517,28 @@ describeEditor( * a paragraph inline reported its own text with no marker, so the bullet the user pressed * was gone the moment the host saved what the document reported. */ + it('edits and selects under the shipped document style, which makes other text unselectable', async () => { + const { page, consoleErrors } = await openPage(browser) + try { + await setContent(page, 'alpha', '

alpha

') + const word = page.locator('#first-surface #editor p') + await word.click() + await page.keyboard.press('End') + await page.keyboard.type(' bravo') + await page.waitForFunction( + () => + document.querySelector('#first-surface #editor p')?.textContent === 'alpha bravo' + ) + // The browser's own gesture, not a Range: `body` is `user-select: none` on the page. + await page.evaluate(() => window.getSelection()?.removeAllRanges()) + await word.dblclick() + expect(await page.evaluate(() => window.getSelection()?.toString().trim())).not.toBe('') + expect(consoleErrors).toEqual([]) + } finally { + await page.close() + } + }, 600_000) + it('reports a typed bullet list as a list, and renders that markdown back as one', async () => { const { page, consoleErrors } = await openPage(browser) try { diff --git a/config/scripts/mobile-web-app-session-dictation-capture.test.mjs b/config/scripts/mobile-web-app-session-dictation-capture.test.mjs index 1c97d515f2e1..c4ff0f86f1a0 100644 --- a/config/scripts/mobile-web-app-session-dictation-capture.test.mjs +++ b/config/scripts/mobile-web-app-session-dictation-capture.test.mjs @@ -177,13 +177,6 @@ describeClosure( expect(closure.local).toContain('src/hooks/use-mobile-dictation.ts') expect(closure.local).toContain('src/hooks/mobile-dictation-audio-chunk.ts') }) - - it('is big enough that finding nothing would mean something', async () => { - const closure = await closureOf(SESSION) - // The largest route of the series; a closure that collapsed would pass every rule above by - // containing nothing to judge. - expect(closure.local.length).toBeGreaterThan(900) - }) }, 240_000 ) diff --git a/config/scripts/mobile-web-app-stack-transition-render.test.mjs b/config/scripts/mobile-web-app-stack-transition-render.test.mjs new file mode 100644 index 000000000000..4420d32acfaf --- /dev/null +++ b/config/scripts/mobile-web-app-stack-transition-render.test.mjs @@ -0,0 +1,354 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { chromium, webkit } from 'playwright-core' +import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs' +import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' +import { MOBILE_WEB_APP_ROUTE_ROOT } from './mobile-web-app-route-manifest.mjs' +import { + createBundleServer, + installShellDouble, + readBridgeBackNames, + readBridgeFaultGrant, + readBridgeProtocolVersion, + readShellCsp +} from './mobile-web-app-render-harness.mjs' + +/** + * The host stack's push and pop on the page, sampled every animation frame in a real browser. + * + * The native stack slides; the page's stack is `HostStack`'s web half, and before it existed the + * page rendered native-stack's web view, which flips `display` and nothing else. The probe tree + * mounts the real `HostStack` (extensionless, so its `.web.tsx` wins as it does on a real route) + * over two marked screens, so the frames measure the stack and not what a screen paints. + */ + +const HOST_ID = 'stack-host' +const LIST_ROUTE = `/${MOBILE_WEB_APP_ROUTE_ROOT}/${HOST_ID}` +const SESSION_HREF = `${LIST_ROUTE}/session/wt-1` +const VIEWPORT = { width: 390, height: 844 } +const SAMPLE_MS = 1500 + +const bundles = mobileWebAppDependenciesPresent() +const describeRender = bundles ? describe : describe.skip + +/** WKWebView carries the page on iOS, so WebKit's Web Animations and keyframes count too. */ +const ENGINES = [ + { + name: 'chromium', + launch: () => { + const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER + return chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) }) + } + }, + { name: 'webkit', launch: () => webkit.launch({ headless: true }) } +] + +const browsers = new Map() +let origin = null +let scratch = null +let server = null +let bridgeVersion = null +let faultGrant = null +let backNames = null + +const layoutSource = ( + hostStackModule +) => `import { HostStack } from ${JSON.stringify(hostStackModule)} +export default function ProbeHostLayout() { + return +} +` + +// Both screens go through the page's real router seam, so Back claims and pops as shipped. +const listSource = (handoffModule) => `import { useEffect } from 'react' +import { View } from 'react-native' +import { useRouteHandoff } from ${JSON.stringify(handoffModule)} +export default function ProbeList() { + const router = useRouteHandoff() + useEffect(() => { + globalThis.__orcaStackProbe = { + push: () => router.push(${JSON.stringify(SESSION_HREF)}), + pushOther: () => router.push(${JSON.stringify(`${LIST_ROUTE}/session/wt-2`)}), + back: () => router.back(), + hardwareBack: () => globalThis.__orcaRenderCheckSendBack() + } + }, [router]) + return +} +` + +// Counts mounts, so a screen remounted for its exit slide reads as a second one. +const sessionSource = (handoffModule) => `import { useEffect } from 'react' +import { View } from 'react-native' +import { useRouteHandoff } from ${JSON.stringify(handoffModule)} +export default function ProbeSession() { + useRouteHandoff() + useEffect(() => { + globalThis.__orcaSessionMounts = (globalThis.__orcaSessionMounts ?? 0) + 1 + }, []) + return +} +` + +beforeAll(async () => { + if (!bundles) { + return + } + const projectDir = fileURLToPath(new URL('../..', import.meta.url)) + const cspHeader = await readShellCsp() + bridgeVersion = await readBridgeProtocolVersion() + faultGrant = await readBridgeFaultGrant() + scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-stack-transition-')) + const routeDir = join(scratch, 'app', MOBILE_WEB_APP_ROUTE_ROOT) + await mkdir(join(routeDir, '[hostId]', 'session'), { recursive: true }) + const navigationDir = join(projectDir, 'mobile', 'src', 'navigation') + const handoffModule = join(navigationDir, 'route-handoff') + backNames = await readBridgeBackNames() + await writeFile(join(routeDir, '_layout.tsx'), layoutSource(join(navigationDir, 'host-stack'))) + await writeFile(join(routeDir, '[hostId]', 'index.tsx'), listSource(handoffModule)) + await writeFile( + join(routeDir, '[hostId]', 'session', '[worktreeId].tsx'), + sessionSource(handoffModule) + ) + const built = await buildMobileWebAppBundle({ + appDir: join(scratch, 'app'), + outDir: join(scratch, 'bundle'), + pageRoutes: [ + { pathname: `/${MOBILE_WEB_APP_ROUTE_ROOT}/[hostId]`, grants: [] }, + { pathname: `/${MOBILE_WEB_APP_ROUTE_ROOT}/[hostId]/session/[worktreeId]`, grants: [] } + ] + }) + const served = await createBundleServer({ outDir: built.outDir, cspHeader }) + server = served.server + origin = served.origin + for (const engine of ENGINES) { + browsers.set(engine.name, await engine.launch()) + } +}, 600_000) + +afterAll(async () => { + for (const browser of browsers.values()) { + await browser.close() + } + server?.close() + if (scratch) { + await rm(scratch, { recursive: true, force: true }) + } +}) + +async function openList(browser, { animation = 'default', reducedMotion = 'no-preference' } = {}) { + const page = await browser.newPage({ viewport: VIEWPORT, reducedMotion }) + await page.addInitScript((value) => { + globalThis.__orcaStackProbeAnimation = value + }, animation) + await page.addInitScript(installShellDouble, { + version: bridgeVersion, + sessionId: 'stack-transition-session', + buildId: 'stack-transition-build', + route: { pathname: LIST_ROUTE, params: {} }, + host: { id: HOST_ID, name: 'Stack Host', endpoint: 'ws://stack', lastConnected: 1 }, + storage: {}, + faultGrant, + backFrame: backNames.frame, + pageRoutes: [ + `/${MOBILE_WEB_APP_ROUTE_ROOT}/[hostId]`, + `/${MOBILE_WEB_APP_ROUTE_ROOT}/[hostId]/session/[worktreeId]` + ], + replies: {} + }) + const errors = [] + page.on('pageerror', (error) => errors.push(`${error.name}: ${error.message}`)) + await page.goto(`${origin}/`, { waitUntil: 'load' }) + await page.waitForFunction(() => globalThis.__orcaStackProbe !== undefined, null, { + timeout: 60_000, + polling: 100 + }) + return { errors, page } +} + +/** + * Runs `action` on the probe, then reads both screens' left edge once per animation frame, and + * which screen a tap at the centre would land on. A hidden screen, or no screen hit, reads as null. + */ +function sampleFrames(page, action, { followUp = null, afterFrames = 0 } = {}) { + return page.evaluate( + ([name, sampleMs, nextAction, followAt]) => + new Promise((resolve) => { + const leftOf = (id) => { + const node = document.querySelector(`[data-testid="${id}"]`) + const rect = node?.getBoundingClientRect() + return rect && rect.width > 0 ? Math.round(rect.left) : null + } + const hitAt = () => + document + .elementFromPoint(innerWidth / 2, innerHeight / 2) + ?.closest('[data-testid^="stack-probe-"]') + ?.getAttribute('data-testid') + ?.replace('stack-probe-', '') ?? null + const frames = [] + const start = performance.now() + globalThis.__orcaStackProbe[name]() + const tick = () => { + if (nextAction !== null && frames.length === followAt) { + globalThis.__orcaStackProbe[nextAction]() + } + frames.push({ + list: leftOf('stack-probe-list'), + session: leftOf('stack-probe-session'), + hit: hitAt(), + // Where the running slide starts, which is how a slide that restarts from 0 shows. + from: document.getAnimations()[0]?.effect?.getKeyframes()[0]?.transform ?? null + }) + if (performance.now() - start < sampleMs) { + requestAnimationFrame(tick) + } else { + resolve(frames) + } + } + requestAnimationFrame(tick) + }), + [action, SAMPLE_MS, followUp, afterFrames] + ) +} + +/** Every element in the document, hidden ones included: a retained slot still counts. */ +const nodeCount = (page) => page.evaluate(() => document.querySelectorAll('*').length) + +async function waitForBackClaim(page) { + await page.waitForFunction( + (name) => + globalThis.__orcaRenderCheckNotifies.some( + (frame) => frame.name === name && frame.claimed === true + ), + backNames.claim, + { timeout: 10_000, polling: 50 } + ) +} + +const between = (left) => left !== null && left > 0 && left < VIEWPORT.width +const sliding = (frames) => frames.filter((frame) => between(frame.session)) +const SESSION_SETTLED = { list: null, session: 0, hit: 'session', from: null } +const LIST_SETTLED = { list: 0, session: null, hit: 'list', from: null } +const sessionMounts = (page) => page.evaluate(() => globalThis.__orcaSessionMounts ?? 0) + +describeRender('the host stack transition on the page', () => { + for (const engine of ENGINES) { + describe(engine.name, () => { + const open = (options) => openList(browsers.get(engine.name), options) + it('slides the session in from the right on push, over the list', async () => { + const { errors, page } = await open() + const frames = await sampleFrames(page, 'push') + const firstShown = frames.find((frame) => frame.session !== null) + // Red on native-stack's web view: the session's first visible frame is already at x = 0. + expect(firstShown?.session).toBeGreaterThan(0) + expect(frames.some((frame) => between(frame.session) && frame.list === 0)).toBe(true) + // The list stays painted under the slide but takes no tap, so a double tap cannot push twice. + expect(sliding(frames).map((frame) => frame.hit)).toEqual(sliding(frames).map(() => null)) + expect(frames.at(-1)).toEqual(SESSION_SETTLED) + expect(errors).toEqual([]) + await page.close() + }, 120_000) + + it('slides the session out to the right on Back, revealing the list', async () => { + const { errors, page } = await open() + await sampleFrames(page, 'push') + expect(await sessionMounts(page)).toBe(1) + const frames = await sampleFrames(page, 'back') + // Red on native-stack's web view: the popped screen is gone on the first frame after Back. + expect(frames.some((frame) => between(frame.session) && frame.list === 0)).toBe(true) + // The screen sliding out is the one that was on screen, not a fresh mount of it. + expect(await sessionMounts(page)).toBe(1) + expect(frames.at(-1)).toEqual(LIST_SETTLED) + expect(errors).toEqual([]) + await page.close() + }, 120_000) + + it('pops with the slide on the device Back key and drops the popped screen', async () => { + const { errors, page } = await open() + const baseline = await nodeCount(page) + await sampleFrames(page, 'push') + await waitForBackClaim(page) + const frames = await sampleFrames(page, 'hardwareBack') + expect(frames.some((frame) => between(frame.session) && frame.list === 0)).toBe(true) + expect(frames.at(-1)).toEqual(LIST_SETTLED) + expect(await nodeCount(page)).toBe(baseline) + expect(errors).toEqual([]) + await page.close() + }, 120_000) + + it('leaves no slot behind when a push interrupts a pop', async () => { + const { errors, page } = await open() + await sampleFrames(page, 'push') + const pushed = await nodeCount(page) + const frames = await sampleFrames(page, 'back', { followUp: 'push', afterFrames: 3 }) + expect(frames.slice(0, 3).some((frame) => between(frame.session))).toBe(true) + expect(frames.at(-1)).toEqual(SESSION_SETTLED) + expect(await nodeCount(page)).toBe(pushed) + expect( + await page.evaluate( + () => document.querySelectorAll('[data-testid="stack-probe-session"]').length + ) + ).toBe(1) + expect(errors).toEqual([]) + await page.close() + }, 120_000) + + it('settles on the list when Back interrupts an unfinished push', async () => { + const { errors, page } = await open() + // Warm the session chunk, so the interrupted push is mid-slide rather than waiting for content. + await sampleFrames(page, 'push') + await sampleFrames(page, 'back') + const baseline = await nodeCount(page) + const mounts = await sessionMounts(page) + const frames = await sampleFrames(page, 'push', { followUp: 'back', afterFrames: 6 }) + expect(between(frames[5].session)).toBe(true) + // Leaves from where the push stopped, not from 0: the exit's first keyframe is mid-screen. + const exitFrom = frames.slice(6).find((frame) => frame.from?.startsWith('matrix'))?.from + const startX = Number(exitFrom?.match(/matrix\(1, 0, 0, 1, ([\d.]+), 0\)/)?.[1]) + expect(between(startX)).toBe(true) + const leaving = frames + .slice(6) + .map((frame) => frame.session) + .filter((left) => left !== null) + expect(leaving).toEqual(leaving.toSorted((a, b) => a - b)) + expect(frames.at(-1)).toEqual(LIST_SETTLED) + expect(await nodeCount(page)).toBe(baseline) + expect(await sessionMounts(page)).toBe(mounts + 1) + expect(errors).toEqual([]) + await page.close() + }, 120_000) + + it('slides a screen out from rest after a push that once interrupted its entry', async () => { + const { errors, page } = await open() + await sampleFrames(page, 'push') + await sampleFrames(page, 'back') + // wt-1 is still entering when wt-2 covers it, so wt-1 settles beneath at rest. + await sampleFrames(page, 'push', { followUp: 'pushOther', afterFrames: 6 }) + await sampleFrames(page, 'back') + const frames = await sampleFrames(page, 'back') + // getKeyframes() reports the keyframe normalized, as translateX(0px). + expect(frames.find((frame) => frame.from !== null)?.from).toMatch(/^translateX\(0(px)?\)$/) + expect(frames.at(-1)).toEqual(LIST_SETTLED) + expect(errors).toEqual([]) + await page.close() + }, 120_000) + + it('swaps instantly in the tablet split view and under reduced motion', async () => { + for (const options of [{ animation: 'none' }, { reducedMotion: 'reduce' }]) { + const { errors, page } = await open(options) + const pushed = await sampleFrames(page, 'push') + expect(pushed.filter((frame) => between(frame.session))).toEqual([]) + expect(pushed.at(-1)).toEqual(SESSION_SETTLED) + const popped = await sampleFrames(page, 'back') + expect(popped.filter((frame) => between(frame.session))).toEqual([]) + expect(popped.at(-1)).toEqual(LIST_SETTLED) + expect(errors).toEqual([]) + await page.close() + } + }, 120_000) + }) + } +}) diff --git a/config/scripts/node-dist-archive-name.mjs b/config/scripts/node-dist-archive-name.mjs new file mode 100644 index 000000000000..bb0dd79e37b9 --- /dev/null +++ b/config/scripts/node-dist-archive-name.mjs @@ -0,0 +1,24 @@ +// Kept apart from update-node-runtime-pin.mjs so the offline check does not load its build graph. + +/** Node's platform suffix for each server target; nodejs.org names Windows `win`, not `win32`. */ +export const NODE_DIST_PLATFORMS = { + 'darwin-arm64': 'darwin-arm64', + 'darwin-x64': 'darwin-x64', + 'linux-arm64-glibc': 'linux-arm64', + 'linux-x64-glibc': 'linux-x64', + 'linux-arm64-musl': 'linux-arm64-musl', + 'linux-x64-musl': 'linux-x64-musl', + 'win32-arm64': 'win-arm64', + 'win32-x64': 'win-x64', + 'linux-x64-glibc217': 'linux-x64-glibc-217' +} + +export function nodeDistArchiveName(version, target) { + // Why .tar.gz over .tar.xz: every POSIX host can extract gzip; xz is not guaranteed. + const extension = target.startsWith('win32-') ? 'zip' : 'tar.gz' + return `node-v${version}-${NODE_DIST_PLATFORMS[target]}.${extension}` +} + +export function windowsImportLibFile(target) { + return `${NODE_DIST_PLATFORMS[target]}/node.lib` +} diff --git a/config/scripts/bun-profile-change-scope.mjs b/config/scripts/node-server-change-scope.mjs similarity index 72% rename from config/scripts/bun-profile-change-scope.mjs rename to config/scripts/node-server-change-scope.mjs index b136876bb6eb..b1a523cd120c 100644 --- a/config/scripts/bun-profile-change-scope.mjs +++ b/config/scripts/node-server-change-scope.mjs @@ -7,15 +7,19 @@ import { ORCAD_CHILD_ENTRY_POINTS, ORCAD_ENTRY_POINT } from './orcad-entry-build.mjs' -import { bunProfileTestPaths } from './bun-profile-test-paths.mjs' -import { bunProfileQualification } from './bun-profile-qualification.mjs' +import { nodeServerTestPaths } from './node-server-test-paths.mjs' +import { nodeServerQualification } from './node-server-qualification.mjs' const ROOT = resolve(import.meta.dirname, '../..') const BUILD_SCRIPTS = [ - 'config/scripts/build-orcad-bun.mjs', + 'config/scripts/build-orcad-node.mjs', + 'config/scripts/server-build-target.mjs', + 'config/scripts/pinned-node-downloads.mjs', 'config/scripts/build-orcad.mjs', + 'config/scripts/build-orcad-prebuilds.mjs', + 'config/scripts/orcad-prebuild-smoke-child.cjs', 'config/scripts/build-windows-process-tree-relay-addon.mjs', - 'config/scripts/run-bun-profile-tests.mjs', + 'config/scripts/run-node-server-tests.mjs', 'config/vitest.config.ts', 'config/scripts/happy-dom-offscreen-canvas.ts', 'config/scripts/happy-dom-mutation-observer-retention.ts', @@ -29,11 +33,11 @@ const ALWAYS_FILES = new Set([ '.npmrc', '.pnpmfile.cjs', 'tsconfig.json', - '.github/workflows/bun-profile-tests.yml', - 'config/scripts/bun-profile-change-scope.mjs', - 'config/scripts/bun-profile-change-scope.test.mjs', - 'config/scripts/bun-profile-qualification.mjs', - 'config/scripts/bun-profile-qualification.test.mjs' + '.github/workflows/node-server-tests.yml', + 'config/scripts/node-server-change-scope.mjs', + 'config/scripts/node-server-change-scope.test.mjs', + 'config/scripts/node-server-qualification.mjs', + 'config/scripts/node-server-qualification.test.mjs' ]) const ALWAYS_PREFIXES = [ '.github/actions/install-node-dependencies/', @@ -49,8 +53,8 @@ const ALWAYS_PREFIXES = [ 'resources/licenses/ripgrep/' ] -export function discoverBunProfileTests(root = ROOT) { - const selectors = bunProfileTestPaths({ artifact: true }) +export function discoverNodeServerTests(root = ROOT) { + const selectors = nodeServerTestPaths({ artifact: true, crossRuntime: true }) return globSync( ['src/**/*.test.{ts,tsx}', 'config/scripts/**/*.test.{ts,mjs}', 'tests/e2e/**/*.unit.test.ts'], { cwd: root } @@ -60,19 +64,19 @@ export function discoverBunProfileTests(root = ROOT) { .sort() } -export async function collectBunProfileInputs({ root = ROOT, entryPoints } = {}) { +export async function collectNodeServerInputs({ root = ROOT, entryPoints } = {}) { const entries = entryPoints ?? [ ORCAD_ENTRY_POINT, ...Object.values(ORCAD_CHILD_ENTRY_POINTS), ...BUILD_SCRIPTS, - ...discoverBunProfileTests(root) + ...discoverNodeServerTests(root) ] const result = await build({ absWorkingDir: root, entryPoints: entries, bundle: true, write: false, - outdir: resolve(root, '.bun-profile-scope'), + outdir: resolve(root, '.node-server-scope'), platform: 'node', format: 'esm', splitting: true, @@ -92,11 +96,11 @@ export async function collectBunProfileInputs({ root = ROOT, entryPoints } = {}) ) } -export async function classifyBunProfileChanges(changedFiles, collect = collectBunProfileInputs) { +export async function classifyNodeServerChanges(changedFiles, collect = collectNodeServerInputs) { if (changedFiles.length === 0) { return { shouldRun: true, reason: 'No complete changed-file evidence' } } - const selectors = bunProfileTestPaths({ artifact: true }) + const selectors = nodeServerTestPaths({ artifact: true, crossRuntime: true }) const forced = changedFiles.find( (file) => ALWAYS_FILES.has(file) || @@ -111,7 +115,9 @@ export async function classifyBunProfileChanges(changedFiles, collect = collectB const matched = changedFiles.find((file) => inputs.has(file)) return { shouldRun: Boolean(matched), - reason: matched ? `Runtime or test dependency changed: ${matched}` : 'No Bun inputs changed' + reason: matched + ? `Runtime or test dependency changed: ${matched}` + : 'No headless-server inputs changed' } } catch (error) { return { @@ -124,9 +130,9 @@ export async function classifyBunProfileChanges(changedFiles, collect = collectB if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const changedFiles = readFileSync(process.argv[2], 'utf8').split('\0').filter(Boolean) - const result = await classifyBunProfileChanges(changedFiles) + const result = await classifyNodeServerChanges(changedFiles) console.log(result.reason) - const policy = bunProfileQualification(changedFiles, result) + const policy = nodeServerQualification(changedFiles, result) const output = `should_run=${result.shouldRun}\nqualification=${policy.qualification}\nrunners=${JSON.stringify(policy.runners)}\n` if (process.env.GITHUB_OUTPUT) { appendFileSync(process.env.GITHUB_OUTPUT, output) diff --git a/config/scripts/bun-profile-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs similarity index 59% rename from config/scripts/bun-profile-change-scope.test.mjs rename to config/scripts/node-server-change-scope.test.mjs index 15258cea96d9..a6910fe9c768 100644 --- a/config/scripts/bun-profile-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -4,12 +4,13 @@ import { tmpdir } from 'node:os' import { afterEach, beforeAll, describe, expect, it } from 'vitest' import { parse } from 'yaml' import { - classifyBunProfileChanges, - collectBunProfileInputs, - discoverBunProfileTests -} from './bun-profile-change-scope.mjs' -import { bunProfileTestPaths } from './bun-profile-test-paths.mjs' + classifyNodeServerChanges, + collectNodeServerInputs, + discoverNodeServerTests +} from './node-server-change-scope.mjs' +import { nodeServerTestPaths } from './node-server-test-paths.mjs' import { ORCAD_CHILD_ENTRY_POINTS } from './orcad-entry-build.mjs' +import { NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts' const temporaryDirs = [] afterEach(() => { @@ -19,7 +20,7 @@ afterEach(() => { }) function moduleTree(files) { - const root = mkdtempSync(join(tmpdir(), 'bun-profile-scope-')) + const root = mkdtempSync(join(tmpdir(), 'node-server-scope-')) temporaryDirs.push(root) for (const [file, source] of Object.entries(files)) { const path = join(root, file) @@ -39,7 +40,7 @@ it('follows static imports, re-exports, dynamic imports and require without exec 'nested/leaf.ts': 'export const value = 4', 'unrelated.ts': 'throw Error("unrelated")' }) - const inputs = await collectBunProfileInputs({ root, entryPoints: ['entry.ts'] }) + const inputs = await collectNodeServerInputs({ root, entryPoints: ['entry.ts'] }) expect([...inputs].sort()).toEqual([ 'dynamic.ts', 'entry.ts', @@ -52,12 +53,12 @@ it('follows static imports, re-exports, dynamic imports and require without exec it('runs the matrix when a dependency is deleted or graph analysis fails', async () => { const root = moduleTree({ 'entry.ts': `import './deleted'` }) - const result = await classifyBunProfileChanges(['deleted.ts'], () => - collectBunProfileInputs({ root, entryPoints: ['entry.ts'] }) + const result = await classifyNodeServerChanges(['deleted.ts'], () => + collectNodeServerInputs({ root, entryPoints: ['entry.ts'] }) ) expect(result.shouldRun).toBe(true) expect(result.reason).toContain('Dependency graph unavailable') - expect((await classifyBunProfileChanges([])).shouldRun).toBe(true) + expect((await classifyNodeServerChanges([])).shouldRun).toBe(true) }) it.each([ @@ -67,7 +68,7 @@ it.each([ ])( 'runs deleted or renamed selected tests even when absent from the graph: %j', async (...files) => { - expect((await classifyBunProfileChanges(files, async () => new Set())).shouldRun).toBe(true) + expect((await classifyNodeServerChanges(files, async () => new Set())).shouldRun).toBe(true) } ) @@ -78,20 +79,20 @@ it.each([ '.npmrc', 'tsconfig.json', 'config/tsconfig.node.json', - 'config/scripts/bun-profile-qualification.mjs', + 'config/scripts/node-server-qualification.mjs', 'config/patches/node-pty@1.1.0.patch', 'native/windows-registry/src/addon.cc', '.github/actions/install-node-dependencies/action.yml', - '.github/workflows/bun-profile-tests.yml', + '.github/workflows/node-server-tests.yml', 'src/main/persistence/profile-state/new-worker.ts' ])('always selects build, native and dynamically opened inputs: %s', async (file) => { - expect((await classifyBunProfileChanges([file], async () => new Set())).shouldRun).toBe(true) + expect((await classifyNodeServerChanges([file], async () => new Set())).shouldRun).toBe(true) }) describe('the actual Bun build and profile-test dependency graph', () => { let inputs beforeAll(async () => { - inputs = await collectBunProfileInputs() + inputs = await collectNodeServerInputs() }, 60_000) it.each([ @@ -100,7 +101,7 @@ describe('the actual Bun build and profile-test dependency graph', () => { 'src/main/ssh/ssh-relay-upload-stage-commands.test.ts', 'src/main/menu/register-app-menu.ts' ])('skips unrelated work: %s', async (file) => { - expect((await classifyBunProfileChanges([file], async () => inputs)).shouldRun).toBe(false) + expect((await classifyNodeServerChanges([file], async () => inputs)).shouldRun).toBe(false) }) it.each([ @@ -116,22 +117,22 @@ describe('the actual Bun build and profile-test dependency graph', () => { 'tests/e2e/daemon-running-work-probe.unit.test.ts' ])('retains the full matrix for a real runtime, worker or test input: %s', async (file) => { expect(inputs.has(file)).toBe(true) - expect((await classifyBunProfileChanges([file], async () => inputs)).shouldRun).toBe(true) + expect((await classifyNodeServerChanges([file], async () => inputs)).shouldRun).toBe(true) }) it('retains all selected tests and the selectors the Bun runner uses', () => { - const tests = discoverBunProfileTests() + const tests = discoverNodeServerTests() expect(tests.length).toBeGreaterThan(80) expect(tests.every((file) => inputs.has(file))).toBe(true) expect( - bunProfileTestPaths().every((selector) => tests.some((file) => file.includes(selector))) + nodeServerTestPaths().every((selector) => tests.some((file) => file.includes(selector))) ).toBe(true) }) }) it('keeps all ten platform jobs and runs them when detection is skipped or fails', () => { const workflow = parse( - readFileSync(new URL('../../.github/workflows/bun-profile-tests.yml', import.meta.url), 'utf8') + readFileSync(new URL('../../.github/workflows/node-server-tests.yml', import.meta.url), 'utf8') ) expect(workflow.on).toHaveProperty('workflow_dispatch') expect(workflow.jobs.changes.if).toBe("github.event_name == 'pull_request'") @@ -158,3 +159,64 @@ it('keeps all ten platform jobs and runs them when detection is skipped or fails expect(job.strategy.matrix.os).toEqual(['ubuntu-22.04', 'ubuntu-24.04-arm']) } }) + +it('builds server glibc slots on glibc 2.28 and the compat slot on glibc 2.17 (design D6)', () => { + const workflow = parse( + readFileSync(new URL('../../.github/workflows/node-server-tests.yml', import.meta.url), 'utf8') + ) + const floor = workflow.jobs.linux_glibc_floor + expect(floor.container).toBe('${{ matrix.image }}') + expect(floor.strategy.matrix.include).toEqual([ + { + os: 'ubuntu-22.04', + image: expect.stringMatching(/^quay\.io\/pypa\/manylinux_2_28_x86_64@sha256:[0-9a-f]{64}$/) + }, + { + os: 'ubuntu-24.04-arm', + image: expect.stringMatching(/^quay\.io\/pypa\/manylinux_2_28_aarch64@sha256:[0-9a-f]{64}$/) + } + ]) + expect(floor.env).toMatchObject({ CC: 'gcc', CXX: 'g++' }) + + const compat = workflow.jobs.linux_glibc217_compat + expect(compat.needs).toEqual(['changes', 'persistence']) + expect(compat.if).toBe(floor.if) + expect(compat['runs-on']).toBe('ubuntu-22.04') + const run = compat.steps.map((step) => step.run ?? '').join('\n') + expect(run).toMatch(/quay\.io\/pypa\/manylinux2014_x86_64@sha256:[0-9a-f]{64} /) + expect(run).toContain('--slot=linux-x64-glibc217 --print-runtime') + expect(run).toContain('build-orcad-prebuilds.mjs --slot=linux-x64-glibc217\n') + expect(run).toContain('--require-slots linux-x64-glibc217') + expect(run).toContain( + 'env -u LD_LIBRARY_PATH node config/scripts/build-orcad-prebuilds.mjs --slot=linux-x64-glibc217 --smoke' + ) +}) + +it('runs the Bun and Node cross-runtime tests on Linux against pinned inputs', () => { + const workflow = parse( + readFileSync(new URL('../../.github/workflows/node-server-tests.yml', import.meta.url), 'utf8') + ) + const steps = workflow.jobs.persistence.steps + const setupBun = steps.find((step) => String(step.uses).startsWith('oven-sh/setup-bun@')) + expect(setupBun.uses).toMatch(/^oven-sh\/setup-bun@[0-9a-f]{40}$/) + // Mirrors LAST_BUN_ORCAD_VERSION in src/main/orcad/orcad-node-slot-fixture.ts. + expect(setupBun.with['bun-version']).toBe('1.4.2') + const build = steps.find((step) => String(step.run).includes('build-orcad-bun.mjs')) + expect(build.env.BUN_ORCAD_COMMIT).toMatch(/^[0-9a-f]{40}$/) + expect(build.run).toContain('ORCA_BUN_ORCAD_SLOT=') + expect(build.run).toContain('BUN_EXECUTABLE=') + for (const step of [setupBun, build]) { + expect(step.if).toBe("runner.os == 'Linux'") + } + expect(steps.map((step) => step.run).join('\n')).toContain( + "pnpm test:node-server --artifact ${{ runner.os == 'Linux' && '--cross-runtime' || '' }}" + ) + const alpine = workflow.jobs.linux_musl.steps.find((step) => + String(step.run).includes('docker run') + ) + expect(alpine.run).toMatch( + new RegExp( + `node:${NODE_RUNTIME_PIN.version.replaceAll('.', '\\.')}-alpine@sha256:[0-9a-f]{64} ` + ) + ) +}) diff --git a/config/scripts/bun-profile-qualification.mjs b/config/scripts/node-server-qualification.mjs similarity index 77% rename from config/scripts/bun-profile-qualification.mjs rename to config/scripts/node-server-qualification.mjs index dafd137f95e2..0fa5923eb4ab 100644 --- a/config/scripts/bun-profile-qualification.mjs +++ b/config/scripts/node-server-qualification.mjs @@ -1,4 +1,4 @@ -export const BUN_PERSISTENCE_RUNNERS = [ +export const NODE_SERVER_RUNNERS = [ 'ubuntu-22.04', 'ubuntu-24.04-arm', 'macos-14', @@ -22,22 +22,26 @@ const PLATFORM_PREFIXES = [ 'src/main/ssh/', 'src/main/wsl/', 'src/relay/', - 'src/shared/child-process/' + 'src/shared/child-process/', + // Every native prebuild slot is compiled and smoked against the pinned runtime. + 'src/shared/node-runtime-pin.ts' ] -export function bunProfileQualification(changedFiles, scope) { +export function nodeServerQualification(changedFiles, scope) { const platformSpecific = changedFiles.some( (file) => // A root manifest can move a native dependency on every platform at once. !file.includes('/') || PLATFORM_PREFIXES.some((prefix) => file.startsWith(prefix)) || - /(?:^|[/.-])(?:windows|win32|wsl|macos|darwin|linux|posix|bun)(?:[/.-]|$)/i.test(file) + /(?:^|[/.-])(?:windows|win32|wsl|macos|darwin|linux|posix|bun|prebuilds?)(?:[/.-]|$)/i.test( + file + ) ) // A pull request qualifies one platform unless the change is platform-flavoured; the push to // main re-qualifies all six, so an unescalated miss surfaces minutes after merge, not a day. const full = changedFiles.length === 0 || scope.graphUnavailable === true || platformSpecific return { qualification: full, - runners: full ? BUN_PERSISTENCE_RUNNERS : ['ubuntu-22.04'] + runners: full ? NODE_SERVER_RUNNERS : ['ubuntu-22.04'] } } diff --git a/config/scripts/bun-profile-qualification.test.mjs b/config/scripts/node-server-qualification.test.mjs similarity index 69% rename from config/scripts/bun-profile-qualification.test.mjs rename to config/scripts/node-server-qualification.test.mjs index 520cd76fb213..a4d367d58b22 100644 --- a/config/scripts/bun-profile-qualification.test.mjs +++ b/config/scripts/node-server-qualification.test.mjs @@ -1,10 +1,10 @@ import { expect, it } from 'vitest' -import { BUN_PERSISTENCE_RUNNERS, bunProfileQualification } from './bun-profile-qualification.mjs' +import { NODE_SERVER_RUNNERS, nodeServerQualification } from './node-server-qualification.mjs' const scope = { shouldRun: true } it('qualifies one platform for a change no platform can alter', () => { - expect(bunProfileQualification(['src/main/runtime/rpc/methods/example.ts'], scope)).toEqual({ + expect(nodeServerQualification(['src/main/runtime/rpc/methods/example.ts'], scope)).toEqual({ qualification: false, runners: ['ubuntu-22.04'] }) @@ -18,7 +18,7 @@ it.each([ '.github/workflows/pr.yml', 'docs/reference/agent-status-store.md' ])('keeps one platform for unflavoured input %s', (file) => { - expect(bunProfileQualification([file], scope).runners).toEqual(['ubuntu-22.04']) + expect(nodeServerQualification([file], scope).runners).toEqual(['ubuntu-22.04']) }) it.each([ @@ -37,18 +37,21 @@ it.each([ 'src/shared/linux-glibc.ts', 'src/main/daemon/entry.ts', 'src/relay/index.ts', - 'src/main/wsl/runner.ts' + 'src/main/wsl/runner.ts', + 'config/scripts/build-orcad-prebuilds.mjs', + 'config/scripts/orcad-prebuild-slot-contents.mjs', + 'src/shared/node-runtime-pin.ts' ])('retains all platforms for platform-flavoured input %s', (file) => { - expect(bunProfileQualification([file], scope)).toEqual({ + expect(nodeServerQualification([file], scope)).toEqual({ qualification: true, - runners: BUN_PERSISTENCE_RUNNERS + runners: NODE_SERVER_RUNNERS }) }) it('fails closed to every platform when the evidence is incomplete', () => { - expect(bunProfileQualification([], scope).qualification).toBe(true) + expect(nodeServerQualification([], scope).qualification).toBe(true) expect( - bunProfileQualification(['src/main/runtime/rpc/methods/example.ts'], { + nodeServerQualification(['src/main/runtime/rpc/methods/example.ts'], { ...scope, graphUnavailable: true }).qualification diff --git a/config/scripts/node-server-test-paths.mjs b/config/scripts/node-server-test-paths.mjs new file mode 100644 index 000000000000..6938919bc7c1 --- /dev/null +++ b/config/scripts/node-server-test-paths.mjs @@ -0,0 +1,32 @@ +/** Mirrors ORCA_REQUIRED_TEST_INPUTS_ENV in src/main/orcad/orcad-node-slot-fixture.ts. */ +export const REQUIRED_TEST_INPUTS_ENV = 'ORCA_REQUIRED_TEST_INPUTS' + +// Need Bun 1.4.2 and the last Bun orcad slot beside the built Node slot (design D7). +export const CROSS_RUNTIME_TEST_PATHS = [ + 'src/main/orcad/orcad-cross-runtime-daemon-adoption.integration.test.ts', + 'src/main/persistence/profile-state/profile-state-cross-runtime.integration.test.ts' +] + +export function nodeServerTestPaths({ artifact = false, crossRuntime = false } = {}) { + return [ + 'src/main/persistence/profile-state', + 'src/main/persistence/loading-store/profile-state', + 'src/main/sqlite', + 'src/main/orcad/orcad-entry.test.ts', + 'src/main/orcad/orcad-push-startup.test.ts', + // The directory, not a prefix: its siblings are POSIX-host unit tests pr.yml already runs. + 'src/main/daemon/pty-subprocess/', + 'src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts', + 'src/main/daemon/pty-subprocess-io-failure-native.test.ts', + ...(artifact + ? [ + 'tests/e2e/daemon-running-work-probe.unit.test.ts', + 'src/main/orcad/orcad-packaged-node-pty.integration.test.ts', + 'src/main/providers/agent-foreground-process-git-bash.win32.test.ts', + 'src/main/orcad/orcad-node-launcher.integration.test.ts', + 'config/scripts/zip-extractor-command.test.mjs' + ] + : []), + ...(crossRuntime ? CROSS_RUNTIME_TEST_PATHS : []) + ] +} diff --git a/config/scripts/orcad-artifact-version.mjs b/config/scripts/orcad-artifact-version.mjs index e7f2284f59fe..7eb20ef2452e 100644 --- a/config/scripts/orcad-artifact-version.mjs +++ b/config/scripts/orcad-artifact-version.mjs @@ -1,14 +1,11 @@ import { createHash } from 'node:crypto' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' -import { - ORCAD_VERSION, - orcadArtifactFilenames, - orcadArtifactHashPrefix -} from '../../src/shared/orcad-artifacts.ts' +import { ORCAD_VERSION, orcadArtifactFilenames } from '../../src/shared/orcad-artifacts.ts' -export function computeOrcadFullVersion(artifactDir, { target = '', agentBrowserFilename } = {}) { - const hash = createHash('sha256').update(orcadArtifactHashPrefix(target)) +/** The runtime enters through `.runtime-node`'s executableSha256 string, never its bytes (design D2). */ +export function computeOrcadFullVersion(artifactDir, { target, agentBrowserFilename }) { + const hash = createHash('sha256') for (const filename of orcadArtifactFilenames(target)) { const artifactPath = join(artifactDir, filename) if (!existsSync(artifactPath)) { diff --git a/config/scripts/orcad-artifact-version.test.mjs b/config/scripts/orcad-artifact-version.test.mjs index 01c1ae3f8ff8..18848568ebd5 100644 --- a/config/scripts/orcad-artifact-version.test.mjs +++ b/config/scripts/orcad-artifact-version.test.mjs @@ -3,11 +3,12 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { - ORCAD_BUILD_TARGET_FILENAME, - ORCAD_RIPGREP_ARTIFACTS, - orcadArtifactFilenames + ORCAD_NODE_RUNTIME_MARKER_FILENAME, + ORCAD_SERVER_TARGET_FILENAME, + orcadArtifactFilenames, + orcadRipgrepArtifact } from '../../src/shared/orcad-artifacts.ts' -import { ORCAD_BUN_TARGETS } from '../../src/shared/orcad-bun-runtime.ts' +import { SERVER_TARGETS } from '../../src/shared/node-runtime-pin.ts' import { orcadAgentBrowserNativeName } from '../../src/shared/orcad-agent-browser-name.ts' import { readOrcadArtifactIdentity } from '../../src/main/orcad/orcad-artifact-identity.ts' import { computeOrcadFullVersion } from './orcad-artifact-version.mjs' @@ -19,7 +20,7 @@ afterEach(() => { } }) -function createArtifactDirectory(target = '') { +function createArtifactDirectory(target = 'linux-x64-glibc') { const directory = mkdtempSync(join(tmpdir(), 'orcad-version-')) directories.push(directory) for (const filename of orcadArtifactFilenames(target)) { @@ -27,22 +28,32 @@ function createArtifactDirectory(target = '') { mkdirSync(dirname(path), { recursive: true }) writeFileSync(path, filename) } - writeFileSync(join(directory, ORCAD_BUILD_TARGET_FILENAME), `${target}\n`) + writeFileSync(join(directory, ORCAD_SERVER_TARGET_FILENAME), `${target}\n`) return directory } describe('standalone runtime version', () => { it('changes when a shipped search binary changes and rejects a missing binary', () => { - const dir = createArtifactDirectory() - const before = computeOrcadFullVersion(dir) - const binary = join(dir, ORCAD_RIPGREP_ARTIFACTS[0]) + const target = 'linux-x64-glibc' + const dir = createArtifactDirectory(target) + const before = computeOrcadFullVersion(dir, { target }) + const binary = join(dir, orcadRipgrepArtifact(target)) writeFileSync(binary, 'updated binary') - expect(computeOrcadFullVersion(dir)).not.toBe(before) + expect(computeOrcadFullVersion(dir, { target })).not.toBe(before) rmSync(binary) - expect(() => computeOrcadFullVersion(dir)).toThrow(ORCAD_RIPGREP_ARTIFACTS[0]) + expect(() => computeOrcadFullVersion(dir, { target })).toThrow(orcadRipgrepArtifact(target)) }) - it.each(ORCAD_BUN_TARGETS)( + it('keys the version on the referenced runtime digest rather than its bytes', () => { + const target = 'darwin-arm64' + const dir = createArtifactDirectory(target) + writeFileSync(join(dir, ORCAD_NODE_RUNTIME_MARKER_FILENAME), `${'a'.repeat(64)}\n`) + const before = computeOrcadFullVersion(dir, { target }) + writeFileSync(join(dir, ORCAD_NODE_RUNTIME_MARKER_FILENAME), `${'b'.repeat(64)}\n`) + expect(computeOrcadFullVersion(dir, { target })).not.toBe(before) + }) + + it.each(SERVER_TARGETS)( 'matches the installed %s identity with and without its optional browser', async (target) => { const dir = createArtifactDirectory(target) diff --git a/config/scripts/orcad-entry-build.mjs b/config/scripts/orcad-entry-build.mjs index fa2c83050ac8..e168430fe0a7 100644 --- a/config/scripts/orcad-entry-build.mjs +++ b/config/scripts/orcad-entry-build.mjs @@ -7,19 +7,11 @@ export const ORCAD_ENTRY_POINT = 'src/main/orcad/main.ts' export const ORCAD_CHILD_ENTRY_POINTS = { watcher: 'src/main/ipc/parcel-watcher-process-entry.ts', daemon: 'src/main/daemon/daemon-entry.ts', - ptyGate: 'src/main/daemon/pty-subprocess/windows-bun-pty-gate-entry.ts', writer: 'src/main/persistence/profile-state/profile-state-writer-worker-entry.ts', backup: 'src/main/persistence/profile-state/profile-state-backup-worker-entry.ts' } -export const ORCAD_EXTERNAL_MODULES = [ - 'electron', - 'node-pty', - '@parcel/watcher', - 'fsevents', - 'bun:ffi', - 'bun:sqlite' -] +export const ORCAD_EXTERNAL_MODULES = ['electron', 'node-pty', '@parcel/watcher', 'fsevents'] // Native binaries are staged separately from every JavaScript entry. export const externalNativeAddons = { diff --git a/config/scripts/orcad-prebuild-slot-contents.mjs b/config/scripts/orcad-prebuild-slot-contents.mjs new file mode 100644 index 000000000000..3f817da251bd --- /dev/null +++ b/config/scripts/orcad-prebuild-slot-contents.mjs @@ -0,0 +1,230 @@ +/** + * What goes into one orcad node-pty prebuild slot, and the manifest that records it. + * + * The manifest is the loader's contract (src/main/orcad/node-pty-prebuilt-slot.ts): per-slot + * N-API level, libc, the highest glibc symbol version the binaries need, and a sha256 per + * shipped file. N-API rather than NODE_MODULE_VERSION is what lets one build serve the pinned + * Node and a host Node (design D2/D6 rung C). + */ +import { createHash } from 'node:crypto' +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join } from 'node:path' + +const require = createRequire(import.meta.url) + +export const MANIFEST_SCHEMA_VERSION = 2 + +/** + * Node 18, the rung C host floor, supports N-API 8 (9 only from 18.17). Pinned here rather + * than left to the headers' default so a header bump cannot silently raise it. + */ +export const SLOT_NAPI_VERSION = 8 + +/** The one export every Node-API addon may carry; any other `node_api_*` name is a post-8 import. */ +const NODE_API_EXPORTS = new Set(['node_api_module_get_api_version_v1']) + +/** + * Opt-in slots outside the default matrix, each beside its own pinned Node (design D6 rung B). + * A glibc 2.17 host has no libstdc++ new enough for anything but GCC 4.8, so the compat slot + * links it statically. + */ +export const COMPAT_SLOTS = Object.freeze({ + 'linux-x64-glibc217': Object.freeze({ platform: 'linux', arch: 'x64', libc: 'glibc' }) +}) + +export function isCompatSlot(slot) { + return Object.hasOwn(COMPAT_SLOTS, slot) +} + +/** The symbol-floor profile a glibc slot is gated at; the desktop keeps its own 2.31 floor. */ +export function slotGlibcFloor(slot) { + const floors = require('./verify-linux-glibc-floor.cjs') + return isCompatSlot(slot) ? floors.COMPAT_SLOT_GLIBC_FLOOR : floors.SERVER_SLOT_GLIBC_FLOOR +} + +/** Refuses a compat slot label on a host that cannot produce it, e.g. arm64 or musl. */ +export function assertCompatSlotHost(slot, { platform, arch, libc }) { + const expected = COMPAT_SLOTS[slot] + if ( + expected && + (expected.platform !== platform || expected.arch !== arch || expected.libc !== libc) + ) { + throw new Error( + `[orcad-prebuilds] ${slot} must be built on ${expected.platform}-${expected.arch}-${expected.libc}, ` + + `not ${platform}-${arch}-${libc}` + ) + } +} + +const SHARED_CXX_RUNTIME = /^(?:libstdc\+\+\.so|libgcc_s\.so)/ + +/** DT_NEEDED entries that break a statically linked C++ runtime, e.g. libstdc++.so.6. */ +export function findSharedCxxRuntimeNeeds(neededLibraries) { + return [...neededLibraries].filter((name) => SHARED_CXX_RUNTIME.test(name)).sort() +} + +/** + * gyp include for every target node-gyp compiles: pins NAPI_VERSION, and on macOS the C++ + * standard, because the official headers' config.gypi says `clang: 0`, which skips + * common.gypi's gnu++20 and leaves older Apple clang at its C++98 default. + */ +export function prebuildCompileGypi({ napi = SLOT_NAPI_VERSION, staticCxxRuntime = false } = {}) { + const conditions = [ + [ + 'OS=="mac"', + { + xcode_settings: { + CLANG_CXX_LANGUAGE_STANDARD: 'gnu++20', + CLANG_CXX_LIBRARY: 'libc++' + } + } + ] + ] + if (staticCxxRuntime) { + conditions.push(['OS=="linux"', { ldflags: ['-static-libstdc++', '-static-libgcc'] }]) + } + const gypi = { target_defaults: { defines: [`NAPI_VERSION=${napi}`], conditions } } + return `${JSON.stringify(gypi, null, 2)}\n` +} + +/** + * `node_api_*` symbol names in a binary other than the module export. Every function added + * after N-API 8 uses that prefix (node_api_symbol_for, node_api_create_property_key_*, ...), + * and import tables store names as plain ASCII in ELF, Mach-O and PE alike. + */ +export function findPostBaselineNodeApiNames(binary) { + const names = new Set(binary.toString('latin1').match(/node_api_[A-Za-z0-9_]+/g) ?? []) + return [...names].filter((name) => !NODE_API_EXPORTS.has(name)).sort() +} + +/** + * Files a slot ships, as `[slot-relative path, source path]`. + * + * Windows: the patched build is conpty.node only (the patch deletes the winpty `pty` and + * `conpty_console_list` gyp targets). conpty.node loads `conpty/conpty.dll` beside itself, + * which starts `OpenConsole.exe` from the same directory; both come from node-pty's + * third_party payload, as rebuild-native-deps.mjs restores them for the desktop. + * `conpty_console_list.node` is upstream's N-API prebuild, the same file the desktop keeps: + * lib/conpty_console_list_agent.js loads it, but only on the non-DLL kill path, which Orca + * never takes (it always passes useConptyDll on win32). Shipped so that path fails soft + * rather than on a missing module. + */ +export function slotSourceFiles({ platform, arch, buildDir, nodePtyDir }) { + if (platform === 'win32') { + const conptyRuntime = windowsConptyRuntimeDir(nodePtyDir, arch) + return [ + ['conpty.node', join(buildDir, 'conpty.node')], + [ + 'conpty_console_list.node', + join(nodePtyDir, 'prebuilds', `win32-${arch}`, 'conpty_console_list.node') + ], + ['conpty/conpty.dll', join(conptyRuntime, 'conpty.dll')], + ['conpty/OpenConsole.exe', join(conptyRuntime, 'OpenConsole.exe')] + ] + } + // Why spawn-helper on macOS only: node-pty posix_spawns it there, and binding.gyp builds it + // only under OS=="mac"; Linux forks directly. + return [ + ['pty.node', join(buildDir, 'pty.node')], + ...(platform === 'darwin' ? [['spawn-helper', join(buildDir, 'spawn-helper')]] : []) + ] +} + +/** The single versioned ConPTY payload node-pty vendors, e.g. third_party/conpty/1.23.251008001/win10-x64. */ +export function windowsConptyRuntimeDir(nodePtyDir, arch) { + const root = join(nodePtyDir, 'third_party', 'conpty') + const versions = existsSync(root) + ? readdirSync(root, { withFileTypes: true }) + .filter( + (entry) => entry.isDirectory() && existsSync(join(root, entry.name, `win10-${arch}`)) + ) + .map((entry) => entry.name) + : [] + if (versions.length !== 1) { + throw new Error( + `[orcad-prebuilds] expected exactly one ConPTY payload for win10-${arch} under ${root}, found ${versions.length}` + ) + } + return join(root, versions[0], `win10-${arch}`) +} + +export function readManifest(prebuildsDir) { + try { + return JSON.parse(readFileSync(join(prebuildsDir, 'manifest.json'), 'utf8')) + } catch { + return null + } +} + +export function sha256Of(path) { + return createHash('sha256').update(readFileSync(path)).digest('hex') +} + +/** Highest strong `GLIBC_x.y` need across the slot's ELF binaries, e.g. '2.17'. */ +export function highestGlibcNeed(binaryPaths, readVersionNeeds) { + const { compareGlibcVersions, parseGlibcVersion } = require('./verify-linux-glibc-floor.cjs') + let highest = null + for (const path of binaryPaths) { + for (const need of readVersionNeeds(path)) { + const match = /^GLIBC_(\d+(?:\.\d+)*)$/.exec(need.name) + if (!need.weak && match) { + if ( + !highest || + compareGlibcVersions(parseGlibcVersion(match[1]), parseGlibcVersion(highest)) > 0 + ) { + highest = match[1] + } + } + } + } + return highest +} + +/** + * Why merge rather than overwrite: CI builds one slot per runner and merges the trees. A + * manifest holding only the last slot would erase the others, and `--require-slots` would + * reject a complete matrix. Mixing builds of different node-pty or N-API levels is refused. + */ +export function mergeManifest(existing, next) { + const current = existing?.schemaVersion === MANIFEST_SCHEMA_VERSION ? existing : null + if (current && (current.version !== next.version || current.napi !== next.napi)) { + throw new Error( + `[orcad-prebuilds] refusing to merge node-pty ${next.version}/N-API ${next.napi} into a ` + + `manifest for ${current.version}/N-API ${current.napi}` + ) + } + const slots = { ...current?.slots, [next.slot]: next.entry } + return { + schemaVersion: MANIFEST_SCHEMA_VERSION, + module: 'node-pty', + version: next.version, + napi: next.napi, + nodeHeaders: next.nodeHeaders, + slots: Object.fromEntries(Object.entries(slots).sort(([a], [b]) => a.localeCompare(b))) + } +} + +/** Problems with the named slots: absent, or a listed file missing or not matching its hash. */ +export function findSlotProblems(manifest, prebuildsDir, requiredSlots) { + if (manifest?.schemaVersion !== MANIFEST_SCHEMA_VERSION) { + return [`manifest.json is missing or not schema ${MANIFEST_SCHEMA_VERSION}`] + } + const problems = [] + for (const slot of requiredSlots) { + const entry = manifest.slots?.[slot] + if (!entry) { + problems.push(`${slot}: not built`) + continue + } + for (const [file, expected] of Object.entries(entry.files ?? {})) { + const path = join(prebuildsDir, slot, ...file.split('/')) + if (!existsSync(path)) { + problems.push(`${slot}/${file}: missing`) + } else if (sha256Of(path) !== expected) { + problems.push(`${slot}/${file}: sha256 does not match the manifest`) + } + } + } + return problems +} diff --git a/config/scripts/orcad-prebuild-slot-contents.test.mjs b/config/scripts/orcad-prebuild-slot-contents.test.mjs new file mode 100644 index 000000000000..a60ddbe817d4 --- /dev/null +++ b/config/scripts/orcad-prebuild-slot-contents.test.mjs @@ -0,0 +1,235 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + assertCompatSlotHost, + COMPAT_SLOTS, + findPostBaselineNodeApiNames, + findSharedCxxRuntimeNeeds, + findSlotProblems, + highestGlibcNeed, + isCompatSlot, + mergeManifest, + prebuildCompileGypi, + sha256Of, + slotGlibcFloor, + slotSourceFiles, + SLOT_NAPI_VERSION, + windowsConptyRuntimeDir +} from './orcad-prebuild-slot-contents.mjs' +import { ORCAD_ADDON_NAPI_VERSION } from '../../src/shared/orcad-artifacts.ts' + +const floors = createRequire(import.meta.url)('./verify-linux-glibc-floor.cjs') +const dirs = [] +const temp = () => { + const dir = mkdtempSync(join(tmpdir(), 'orcad-slot-contents-')) + dirs.push(dir) + return dir +} +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +const entry = (files = {}) => ({ + platform: 'darwin', + arch: 'arm64', + libc: 'none', + glibc: null, + napi: 8, + files +}) +const next = (slot, overrides = {}) => ({ + slot, + version: '1.1.0', + napi: 8, + nodeHeaders: '24.21.0', + entry: entry(), + ...overrides +}) + +describe('N-API pinning', () => { + it('pins N-API 8 so a host Node 18 (rung C) can load every slot', () => { + expect(SLOT_NAPI_VERSION).toBe(8) + // The client's rung C host-Node gate must ask for exactly what the slots are built against. + expect(ORCAD_ADDON_NAPI_VERSION).toBe(SLOT_NAPI_VERSION) + expect(JSON.parse(prebuildCompileGypi()).target_defaults.defines).toEqual(['NAPI_VERSION=8']) + }) + + it('pins the macOS C++ standard that the headers config.gypi (clang: 0) would skip', () => { + expect(JSON.parse(prebuildCompileGypi()).target_defaults.conditions).toEqual([ + [ + 'OS=="mac"', + { xcode_settings: { CLANG_CXX_LANGUAGE_STANDARD: 'gnu++20', CLANG_CXX_LIBRARY: 'libc++' } } + ] + ]) + }) + + it('links the C++ runtime statically only when asked', () => { + const conditions = (options) => + JSON.parse(prebuildCompileGypi(options)).target_defaults.conditions + expect(conditions({ staticCxxRuntime: false })).toHaveLength(1) + expect(conditions({ staticCxxRuntime: true })).toContainEqual([ + 'OS=="linux"', + { ldflags: ['-static-libstdc++', '-static-libgcc'] } + ]) + expect(JSON.parse(prebuildCompileGypi({ napi: 9 })).target_defaults.defines).toEqual([ + 'NAPI_VERSION=9' + ]) + }) + + it('flags node_api_* imports but not the module version export', () => { + const binary = Buffer.from( + '\0_napi_register_module_v1\0_node_api_module_get_api_version_v1\0napi_create_object\0' + ) + expect(findPostBaselineNodeApiNames(binary)).toEqual([]) + const newer = Buffer.concat([binary, Buffer.from('node_api_symbol_for\0')]) + expect(findPostBaselineNodeApiNames(newer)).toEqual(['node_api_symbol_for']) + }) +}) + +describe('glibc floors per slot', () => { + it('gates default glibc slots at 2.28 and the compat slot at 2.17, never the desktop 2.31', () => { + expect(slotGlibcFloor('linux-x64-glibc')).toBe(floors.SERVER_SLOT_GLIBC_FLOOR) + expect(slotGlibcFloor('linux-arm64-glibc')).toBe(floors.SERVER_SLOT_GLIBC_FLOOR) + expect(slotGlibcFloor('linux-x64-glibc217')).toBe(floors.COMPAT_SLOT_GLIBC_FLOOR) + expect(floors.SERVER_SLOT_GLIBC_FLOOR.families[0]).toEqual({ prefix: 'GLIBC_', floor: [2, 28] }) + expect(floors.COMPAT_SLOT_GLIBC_FLOOR.families[0]).toEqual({ prefix: 'GLIBC_', floor: [2, 17] }) + }) + + it('keeps the compat slot out of the default matrix', () => { + expect(Object.keys(COMPAT_SLOTS)).toEqual(['linux-x64-glibc217']) + expect(isCompatSlot('linux-x64-glibc217')).toBe(true) + expect(isCompatSlot('linux-x64-glibc')).toBe(false) + expect(isCompatSlot('toString')).toBe(false) + }) + + it('refuses the compat label on a host that cannot build it', () => { + const host = { platform: 'linux', arch: 'x64', libc: 'glibc' } + expect(() => assertCompatSlotHost('linux-x64-glibc217', host)).not.toThrow() + expect(() => assertCompatSlotHost('linux-x64-glibc217', { ...host, arch: 'arm64' })).toThrow( + 'linux-x64-glibc217 must be built on linux-x64-glibc, not linux-arm64-glibc' + ) + expect(() => assertCompatSlotHost('linux-x64-glibc217', { ...host, libc: 'musl' })).toThrow() + expect(() => assertCompatSlotHost('linux-arm64-musl', { ...host, libc: 'musl' })).not.toThrow() + }) + + it('names shared C++ runtime needs a static compat slot must not have', () => { + expect( + findSharedCxxRuntimeNeeds( + new Set(['libc.so.6', 'libstdc++.so.6', 'libutil.so.1', 'libgcc_s.so.1']) + ) + ).toEqual(['libgcc_s.so.1', 'libstdc++.so.6']) + expect(findSharedCxxRuntimeNeeds(new Set(['libc.so.6', 'libutil.so.1']))).toEqual([]) + }) +}) + +describe('slotSourceFiles', () => { + it('ships pty.node everywhere POSIX and spawn-helper only on macOS', () => { + const names = (platform) => + slotSourceFiles({ platform, arch: 'x64', buildDir: '/b', nodePtyDir: '/p' }).map(([f]) => f) + expect(names('linux')).toEqual(['pty.node']) + expect(names('darwin')).toEqual(['pty.node', 'spawn-helper']) + }) + + it('ships conpty.node with the vendored ConPTY runtime and console-list module on Windows', () => { + const nodePtyDir = temp() + mkdirSync(join(nodePtyDir, 'third_party', 'conpty', '1.23.251008001', 'win10-arm64'), { + recursive: true + }) + const files = slotSourceFiles({ platform: 'win32', arch: 'arm64', buildDir: '/b', nodePtyDir }) + expect(files.map(([file]) => file)).toEqual([ + 'conpty.node', + 'conpty_console_list.node', + 'conpty/conpty.dll', + 'conpty/OpenConsole.exe' + ]) + expect(files[2][1]).toBe( + join(nodePtyDir, 'third_party', 'conpty', '1.23.251008001', 'win10-arm64', 'conpty.dll') + ) + }) + + it('refuses an ambiguous or absent ConPTY payload rather than picking one', () => { + const nodePtyDir = temp() + expect(() => windowsConptyRuntimeDir(nodePtyDir, 'x64')).toThrow(/found 0/) + for (const version of ['1.22.0', '1.23.0']) { + mkdirSync(join(nodePtyDir, 'third_party', 'conpty', version, 'win10-x64'), { + recursive: true + }) + } + expect(() => windowsConptyRuntimeDir(nodePtyDir, 'x64')).toThrow(/found 2/) + }) +}) + +describe('highestGlibcNeed', () => { + it('takes the highest strong GLIBC_ need and ignores weak and libstdc++ needs', () => { + const needs = { + a: [ + { name: 'GLIBC_2.17', weak: false }, + { name: 'GLIBC_2.34', weak: true }, + { name: 'GLIBCXX_3.4.28', weak: false } + ], + b: [{ name: 'GLIBC_2.2.5', weak: false }] + } + expect(highestGlibcNeed(['a', 'b'], (path) => needs[path])).toBe('2.17') + expect(highestGlibcNeed([], () => [])).toBeNull() + }) +}) + +describe('mergeManifest', () => { + it('accumulates slots across the per-runner CI builds', () => { + // Overwriting would erase every other runner's record, and the release gate would then + // reject a matrix that is actually complete. + const first = mergeManifest(null, next('linux-x64-glibc')) + const second = mergeManifest(first, next('darwin-arm64')) + expect(Object.keys(second.slots)).toEqual(['darwin-arm64', 'linux-x64-glibc']) + expect(second).toMatchObject({ schemaVersion: 2, module: 'node-pty', napi: 8 }) + }) + + it('replaces a slot rebuilt twice instead of duplicating it', () => { + const once = mergeManifest(null, next('darwin-arm64')) + const twice = mergeManifest(once, next('darwin-arm64', { entry: entry({ 'pty.node': 'b' }) })) + expect(twice.slots['darwin-arm64'].files).toEqual({ 'pty.node': 'b' }) + }) + + it('refuses to merge builds of different N-API levels or node-pty versions', () => { + const once = mergeManifest(null, next('darwin-arm64')) + expect(() => mergeManifest(once, next('win32-x64', { napi: 9 }))).toThrow(/refusing to merge/) + expect(() => mergeManifest(once, next('win32-x64', { version: '1.2.0' }))).toThrow( + /refusing to merge/ + ) + }) + + it('drops a schema 1 manifest rather than mixing its ABI-gated slots in', () => { + const merged = mergeManifest({ nodeAbi: '127', slots: ['linux-x64-glibc'] }, next('win32-x64')) + expect(Object.keys(merged.slots)).toEqual(['win32-x64']) + }) +}) + +describe('findSlotProblems', () => { + it('passes only when every named slot is present and every file matches its hash', () => { + const dir = temp() + mkdirSync(join(dir, 'darwin-arm64')) + writeFileSync(join(dir, 'darwin-arm64', 'pty.node'), 'binary') + const manifest = mergeManifest( + null, + next('darwin-arm64', { + entry: entry({ 'pty.node': sha256Of(join(dir, 'darwin-arm64', 'pty.node')) }) + }) + ) + expect(findSlotProblems(manifest, dir, ['darwin-arm64'])).toEqual([]) + expect(findSlotProblems(manifest, dir, ['darwin-arm64', 'win32-x64'])).toEqual([ + 'win32-x64: not built' + ]) + writeFileSync(join(dir, 'darwin-arm64', 'pty.node'), 'torn') + expect(findSlotProblems(manifest, dir, ['darwin-arm64'])).toEqual([ + 'darwin-arm64/pty.node: sha256 does not match the manifest' + ]) + expect(findSlotProblems(null, dir, ['darwin-arm64'])).toEqual([ + 'manifest.json is missing or not schema 2' + ]) + }) +}) diff --git a/config/scripts/orcad-prebuild-smoke-child.cjs b/config/scripts/orcad-prebuild-smoke-child.cjs new file mode 100644 index 000000000000..67f784eaf69a --- /dev/null +++ b/config/scripts/orcad-prebuild-smoke-child.cjs @@ -0,0 +1,61 @@ +// Runs under the pinned Node: load the staged node-pty, open a PTY, echo, and exit. +const { join } = require('node:path') +const { tmpdir } = require('node:os') + +const [nodePtyDir, expectedVersion] = process.argv.slice(2) +if (!nodePtyDir || !expectedVersion) { + throw new Error('usage: orcad-prebuild-smoke-child.cjs ') +} +if (process.version !== `v${expectedVersion}`) { + throw new Error(`smoke must run on the pinned Node v${expectedVersion}, got ${process.version}`) +} + +const pty = require(nodePtyDir) +if (process.platform === 'win32') { + // Loaded only by the non-DLL kill path; prove the shipped module still loads under this Node. + const { loadNativeModule } = require(join(nodePtyDir, 'lib', 'utils')) + loadNativeModule('conpty_console_list') +} + +const token = 'orca-prebuild-smoke-ok' +let output = '' +let settled = false +// Why the pinned node as the child: no shell, so no platform quoting and no cmd.exe /c. +const child = pty.spawn( + process.execPath, + ['-e', `process.stdout.write(${JSON.stringify(token)})`], + { + name: 'xterm-color', + cols: 80, + rows: 24, + cwd: tmpdir(), + env: process.env, + ...(process.platform === 'win32' ? { useConptyDll: true } : {}) + } +) + +const timeout = setTimeout(() => { + settled = true + child.kill() + process.stderr.write(`prebuild smoke timed out; output so far: ${JSON.stringify(output)}\n`) + process.exit(1) +}, 20_000) + +child.onData((data) => { + output = `${output}${data}`.slice(-4096) +}) +child.onExit(({ exitCode }) => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + if (exitCode !== 0 || !output.includes(token)) { + process.stderr.write( + `prebuild smoke failed: exit=${exitCode} output=${JSON.stringify(output)}\n` + ) + process.exit(1) + } + process.stdout.write(`${token} node=${process.version} napi=${process.versions.napi}\n`) + process.exit(0) +}) diff --git a/config/scripts/orcad-prebuild-smoke.mjs b/config/scripts/orcad-prebuild-smoke.mjs new file mode 100644 index 000000000000..363e3ea7d621 --- /dev/null +++ b/config/scripts/orcad-prebuild-smoke.mjs @@ -0,0 +1,60 @@ +// Load-and-spawn smoke for one built slot, under the PINNED Node rather than the build host's. +import { chmodSync, cpSync, existsSync, mkdirSync, rmSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts' +import { findSlotProblems, readManifest } from './orcad-prebuild-slot-contents.mjs' +import { ensurePinnedNodeExecutable } from './pinned-node-downloads.mjs' +import { runProcessSync } from './script-child-process.mjs' + +const require = createRequire(import.meta.url) + +/** + * Stages node-pty's JS with ONLY the slot's binaries in build/Release, the way orcad's installer + * lays them out. No prebuilds/ directory is copied, so node-pty cannot fall back to upstream's. + */ +export function stageSmokeNodePty({ slotDir, stageDir }) { + const sourceDir = dirname(require.resolve('node-pty/package.json')) + const nodePtyDir = join(stageDir, 'node_modules', 'node-pty') + rmSync(stageDir, { recursive: true, force: true }) + mkdirSync(nodePtyDir, { recursive: true }) + cpSync(join(sourceDir, 'package.json'), join(nodePtyDir, 'package.json')) + cpSync(join(sourceDir, 'lib'), join(nodePtyDir, 'lib'), { recursive: true }) + cpSync(slotDir, join(nodePtyDir, 'build', 'Release'), { recursive: true }) + const helper = join(nodePtyDir, 'build', 'Release', 'spawn-helper') + if (existsSync(helper)) { + chmodSync(helper, 0o755) + } + return nodePtyDir +} + +export async function runOrcadPrebuildSmoke({ slot, prebuildsDir }) { + const problems = findSlotProblems(readManifest(prebuildsDir), prebuildsDir, [slot]) + if (problems.length > 0) { + throw new Error(`[orcad-prebuilds] cannot smoke ${slot}: ${problems.join('; ')}`) + } + const node = await ensurePinnedNodeExecutable({ target: slot }) + const nodePtyDir = stageSmokeNodePty({ + slotDir: join(prebuildsDir, slot), + stageDir: join(prebuildsDir, '..', 'orcad-prebuild-smoke', slot) + }) + const result = runProcessSync({ + program: node, + args: [ + join(import.meta.dirname, 'orcad-prebuild-smoke-child.cjs'), + nodePtyDir, + NODE_RUNTIME_PIN.version + ], + timeoutMs: 60_000 + }) + process.stdout.write(result.stdout) + process.stderr.write(result.stderr) + if (result.code !== 0) { + throw new Error( + `[orcad-prebuilds] ${slot} smoke failed under pinned Node ${NODE_RUNTIME_PIN.version} (exit ${result.code}${result.timedOut ? ', timed out' : ''})` + ) + } + console.log( + `[orcad-prebuilds] ${slot} loads and spawns under pinned Node ${NODE_RUNTIME_PIN.version}` + ) +} diff --git a/config/scripts/orcad-template-test-fixture.mjs b/config/scripts/orcad-template-test-fixture.mjs index 2f11a32e83f1..280d0ef45e87 100644 --- a/config/scripts/orcad-template-test-fixture.mjs +++ b/config/scripts/orcad-template-test-fixture.mjs @@ -2,12 +2,14 @@ import { createHash } from 'node:crypto' import { mkdir, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { - ORCAD_BUILD_TARGET_FILENAME, + ORCAD_NODE_RUNTIME_MARKER_FILENAME, + ORCAD_SERVER_TARGET_FILENAME, ORCAD_TEMPLATE_MANIFEST_FILENAME, ORCAD_TEMPLATE_TARGETS_DIR, - orcadTemplateCommonFilenames + orcadTemplateCommonFilenames, + orcadTemplateTargetFilenames } from '../../src/shared/orcad-artifacts.ts' -import { ORCAD_TEMPLATE_TARGETS } from '../../src/shared/orcad-bun-runtime.ts' +import { NODE_RUNTIME_ASSETS, ORCAD_TEMPLATE_TARGETS } from '../../src/shared/node-runtime-pin.ts' async function write(path, contents) { await mkdir(dirname(path), { recursive: true }) @@ -15,11 +17,20 @@ async function write(path, contents) { return createHash('sha256').update(contents).digest('hex') } +function targetFileContents(target, filename) { + if (filename === ORCAD_SERVER_TARGET_FILENAME) { + return `${target}\n` + } + if (filename === ORCAD_NODE_RUNTIME_MARKER_FILENAME) { + return `${NODE_RUNTIME_ASSETS[target].executableSha256}\n` + } + return `${target}:${filename}` +} + export async function writeOrcadTemplateTestFixture(resourcesDir) { const templateDir = join(resourcesDir, 'orcad-template') - const commonFilenames = orcadTemplateCommonFilenames() const commonSha256 = {} - for (const filename of commonFilenames) { + for (const filename of orcadTemplateCommonFilenames()) { commonSha256[filename] = await write( join(templateDir, ...filename.split('/')), Buffer.from(`common:${filename}`) @@ -28,10 +39,14 @@ export async function writeOrcadTemplateTestFixture(resourcesDir) { const targets = {} for (const target of ORCAD_TEMPLATE_TARGETS) { const targetDir = join(templateDir, ORCAD_TEMPLATE_TARGETS_DIR, target) - targets[target] = { - targetSha256: await write(join(targetDir, ORCAD_BUILD_TARGET_FILENAME), `${target}\n`), - watcherSha256: await write(join(targetDir, 'watcher.node'), `watcher:${target}`) + const files = {} + for (const filename of orcadTemplateTargetFilenames(target)) { + files[filename] = await write( + join(targetDir, ...filename.split('/')), + targetFileContents(target, filename) + ) } + targets[target] = { files } } const browserName = 'agent-browser-linux-x64' targets['linux-x64-glibc'] = { @@ -44,7 +59,7 @@ export async function writeOrcadTemplateTestFixture(resourcesDir) { } await writeFile( join(templateDir, ORCAD_TEMPLATE_MANIFEST_FILENAME), - JSON.stringify({ schemaVersion: 2, commonSha256, targets }) + JSON.stringify({ schemaVersion: 3, commonSha256, targets }) ) return templateDir } diff --git a/config/scripts/orcad-terminal-smoke-change-scope.mjs b/config/scripts/orcad-terminal-smoke-change-scope.mjs index 9dd58dab20ce..3ece0aa5b3a9 100644 --- a/config/scripts/orcad-terminal-smoke-change-scope.mjs +++ b/config/scripts/orcad-terminal-smoke-change-scope.mjs @@ -1,13 +1,14 @@ import { readFileSync } from 'node:fs' import { pathToFileURL } from 'node:url' -import { classifyBunProfileChanges, collectBunProfileInputs } from './bun-profile-change-scope.mjs' +import { classifyNodeServerChanges, collectNodeServerInputs } from './node-server-change-scope.mjs' import { ORCAD_CHILD_ENTRY_POINTS, ORCAD_ENTRY_POINT } from './orcad-entry-build.mjs' const ENTRY_POINTS = [ ORCAD_ENTRY_POINT, ...Object.values(ORCAD_CHILD_ENTRY_POINTS), 'src/cli/index.ts', - 'config/scripts/build-orcad-bun.mjs', + 'config/scripts/build-orcad-node.mjs', + 'config/scripts/build-orcad-prebuilds.mjs', 'config/scripts/build-orcad.mjs', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', @@ -19,7 +20,7 @@ const ENTRY_POINTS = [ ] export function collectOrcadTerminalSmokeInputs() { - return collectBunProfileInputs({ entryPoints: ENTRY_POINTS }) + return collectNodeServerInputs({ entryPoints: ENTRY_POINTS }) } export async function classifyOrcadTerminalSmokeChanges( @@ -42,8 +43,8 @@ export async function classifyOrcadTerminalSmokeChanges( if (forced) { return { shouldRun: true, reason: `Smoke input changed: ${forced}` } } - // Retain the Bun gate's native, worker, toolchain and failed-analysis safeguards. - return classifyBunProfileChanges(changedFiles, collect) + // Retain the headless-server gate's native, worker, toolchain and failed-analysis safeguards. + return classifyNodeServerChanges(changedFiles, collect) } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { diff --git a/config/scripts/orcad-terminal-smoke-change-scope.test.mjs b/config/scripts/orcad-terminal-smoke-change-scope.test.mjs index e0e978800fc7..5ee88ff0492a 100644 --- a/config/scripts/orcad-terminal-smoke-change-scope.test.mjs +++ b/config/scripts/orcad-terminal-smoke-change-scope.test.mjs @@ -51,12 +51,12 @@ describe('the actual terminal smoke dependency graph', () => { 'src/main/worker-thread-entry-path.ts', 'src/cli/index.ts', 'config/scripts/runtime-serve-terminal-smoke.mjs', - 'config/scripts/build-orcad-bun.mjs', + 'config/scripts/build-orcad-node.mjs', 'config/scripts/build-orcad.mjs', 'config/scripts/profile-state-worker-smoke.mjs', 'config/scripts/install-dev-cli.mjs', 'config/scripts/verify-cli-bin.mjs', - 'config/scripts/bun-profile-change-scope.mjs' + 'config/scripts/node-server-change-scope.mjs' ])('retains the runtime, CLI, build or smoke dependency: %s', async (file) => { expect(inputs.has(file)).toBe(true) expect((await classifyOrcadTerminalSmokeChanges([file], async () => inputs)).shouldRun).toBe( diff --git a/config/scripts/orcad-watcher-package.mjs b/config/scripts/orcad-watcher-package.mjs index 5107d23fe58f..e1c8625ad4e6 100644 --- a/config/scripts/orcad-watcher-package.mjs +++ b/config/scripts/orcad-watcher-package.mjs @@ -4,7 +4,7 @@ import { createRequire } from 'node:module' import { join, resolve } from 'node:path' import { x as extractTar } from 'tar' import { parseAllDocuments } from 'yaml' -import { ORCAD_BUN_TARGETS } from '../../src/shared/orcad-bun-runtime.ts' +import { SERVER_TARGETS } from '../../src/shared/node-runtime-pin.ts' const root = resolve(import.meta.dirname, '../..') const require = createRequire(import.meta.url) @@ -22,7 +22,7 @@ export function parseWatcherLockfile(contents) { } export function watcherPackageIdentity(target, version, lockfile) { - if (!ORCAD_BUN_TARGETS.includes(target)) { + if (!SERVER_TARGETS.includes(target)) { throw new Error(`Unsupported watcher target: ${target}`) } const name = `@parcel/watcher-${target}` diff --git a/config/scripts/orchestration-skill-guidance.test.mjs b/config/scripts/orchestration-skill-guidance.test.mjs index 2ed4288e2236..522249fe5a4c 100644 --- a/config/scripts/orchestration-skill-guidance.test.mjs +++ b/config/scripts/orchestration-skill-guidance.test.mjs @@ -318,7 +318,7 @@ describe('owned orchestration references', () => { expect(squash(reference)).toContain('An empty `check` never means you were replaced') }) - it('keeps heartbeat and worker_done recipes bound to the injected capability', () => { + it('keeps heartbeat and worker_done recipes bound to the injected Dispatch', () => { const reference = readReference('worker-contract.md') const recipes = [...reference.matchAll(/```text\n([\s\S]*?)```/gu)].map((match) => match[1]) const heartbeat = recipes.find((recipe) => recipe.includes('--type heartbeat')) @@ -326,7 +326,7 @@ describe('owned orchestration references', () => { for (const recipe of [heartbeat, workerDone]) { expect(recipe).toContain('--from ') - expect(recipe).toContain('--dispatch-capability ') + expect(recipe).not.toContain('--dispatch-capability') expect(recipe).toContain('--task-id --dispatch-id ') } expect(workerDone).not.toContain('--files-modified') diff --git a/config/scripts/pinned-node-downloads.mjs b/config/scripts/pinned-node-downloads.mjs new file mode 100644 index 000000000000..f4bf1e2bd930 --- /dev/null +++ b/config/scripts/pinned-node-downloads.mjs @@ -0,0 +1,93 @@ +// Hash-verified downloads of the pinned Node's build inputs (headers, node.lib) and executable. +import { chmodSync, copyFileSync, existsSync, mkdirSync, rmSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { + NODE_RUNTIME_PIN, + isWindowsServerTarget, + nodeRuntimeAsset, + nodeRuntimeExecutablePath, + nodeRuntimeHeadersUrl, + nodeRuntimeReleaseUrl +} from '../../src/shared/node-runtime-pin.ts' +import { download, extract, sha256File } from './update-node-runtime-pin.mjs' + +const ROOT = resolve(import.meta.dirname, '..', '..') + +export function pinnedNodeCacheDir(env = process.env) { + return env.ORCA_NODE_RUNTIME_CACHE_DIR || join(ROOT, 'out', 'node-runtime-cache') +} + +/** Reuses a cached file only when it still hashes to the pin; anything else is re-fetched. */ +export async function fetchPinnedFile({ url, destination, sha256 }) { + if (existsSync(destination) && (await sha256File(destination)) === sha256) { + return destination + } + mkdirSync(join(destination, '..'), { recursive: true }) + const partial = `${destination}.partial-${process.pid}` + await download(url, partial) + const actual = await sha256File(partial) + if (actual !== sha256) { + rmSync(partial, { force: true }) + throw new Error(`${url} hashed ${actual}, but the Node runtime pin expects ${sha256}`) + } + rmSync(destination, { force: true }) + copyFileSync(partial, destination) + rmSync(partial, { force: true }) + return destination +} + +/** + * A node-gyp `--nodedir` for `target`, built from the pinned headers tarball. + * + * Why per target: on Windows node-gyp links `/Release/node.lib`, which differs by + * arch and is not in the headers tarball. + */ +export async function preparePinnedNodeDir({ target, workDir, cacheDir = pinnedNodeCacheDir() }) { + const { version, headers } = NODE_RUNTIME_PIN + const tarball = await fetchPinnedFile({ + url: nodeRuntimeHeadersUrl(), + destination: join(cacheDir, headers.file), + sha256: headers.sha256 + }) + rmSync(workDir, { recursive: true, force: true }) + extract(tarball, workDir, `node-v${version}/include`) + const nodeDir = join(workDir, `node-v${version}`) + if (isWindowsServerTarget(target)) { + const lib = NODE_RUNTIME_PIN.windowsImportLibs[target] + const cached = await fetchPinnedFile({ + url: nodeRuntimeReleaseUrl('official', lib.file, version), + destination: join(cacheDir, `node-v${version}-${lib.file.replace('/', '-')}`), + sha256: lib.sha256 + }) + mkdirSync(join(nodeDir, 'Release'), { recursive: true }) + copyFileSync(cached, join(nodeDir, 'Release', 'node.lib')) + } + return nodeDir +} + +/** The pinned `node` for a default or compat `target`, verified against both hashes. */ +export async function ensurePinnedNodeExecutable({ target, cacheDir = pinnedNodeCacheDir() }) { + const asset = nodeRuntimeAsset(target) + if (!asset) { + throw new Error(`The Node runtime pin has no asset for ${target}`) + } + const member = nodeRuntimeExecutablePath(target, asset.archive) + const executable = join(cacheDir, member) + if (existsSync(executable) && (await sha256File(executable)) === asset.executableSha256) { + return executable + } + const archive = await fetchPinnedFile({ + url: nodeRuntimeReleaseUrl(asset.source, asset.archive), + destination: join(cacheDir, asset.archive), + sha256: asset.archiveSha256 + }) + extract(archive, cacheDir, member) + const actual = await sha256File(executable) + if (actual !== asset.executableSha256) { + throw new Error(`${member} hashed ${actual}, but the pin expects ${asset.executableSha256}`) + } + if (process.platform !== 'win32') { + chmodSync(executable, 0o755) + } + return executable +} diff --git a/config/scripts/pr-code-change-scope-daemon-protocol.test.mjs b/config/scripts/pr-code-change-scope-daemon-protocol.test.mjs new file mode 100644 index 000000000000..014b83dd9fae --- /dev/null +++ b/config/scripts/pr-code-change-scope-daemon-protocol.test.mjs @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { classifyPrJobs } from './pr-code-change-scope.mjs' + +describe('daemon protocol crossing gate routing', () => { + it('runs the gate when the protocol constants change', () => { + expect(classifyPrJobs(['src/main/daemon/daemon-protocol-version.ts'])).toMatchObject({ + should_run: true, + 'cross-version-wire': true, + package: true, + package_windows: true + }) + }) + + it.each([ + 'config/scripts/daemon-protocol-facts.mjs', + 'config/scripts/check-daemon-protocol-crossing.mjs', + 'config/scripts/stable-release-tags.mjs' + ])('runs the gate when its checker %s changes', (file) => { + expect(classifyPrJobs([file])).toMatchObject({ should_run: true, 'cross-version-wire': true }) + }) +}) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 40ef234fd493..88627245173b 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -47,6 +47,7 @@ const GIT_COMPAT_PREFIXES = [ '.github/actions/prepare-git-compatibility/', 'src/shared/git-', 'src/shared/review-head-tracking-ref', + 'src/shared/worktree/local-base-branch-fast-forward', 'src/main/git/', 'src/relay/git-', 'config/scripts/git-binary-compatibility' @@ -143,6 +144,13 @@ function changesMobileWebApp(changedFiles) { const CROSS_VERSION_WIRE_PREFIXES = [ 'tests/e2e/cross-version-wire/', + 'config/scripts/stable-release-tags', + // The R1 daemon protocol crossing gate runs in this job. + 'config/scripts/daemon-protocol-facts', + 'config/scripts/check-daemon-protocol-crossing', + // R3 runtime launcher protocol ratchet; a bump always routes here via the protocol file. + 'config/scripts/check-runtime-launcher-protocol-ratchet', + 'src/main/daemon/daemon-protocol-version.ts', 'src/shared/protocol-version', 'src/shared/terminal-stream-protocol', 'src/shared/browser-client-host-protocol', @@ -286,6 +294,7 @@ const WINDOWS_PACKAGE_TESTS = [ 'src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts', 'src/main/codex/windows-hook-command.test.ts', 'src/main/codex/windows-hook-upgrade.test.ts', + 'src/main/codex/hook-service-managed-install.test.ts', 'src/main/windows/windows-pty-job.win32.test.ts', 'src/main/windows/windows-msys-job.win32.test.ts', 'src/main/providers/agent-foreground-process-git-bash.win32.test.ts', diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 01cb077b1794..1d6ab0475449 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -132,6 +132,12 @@ describe('per-job path classification', () => { expectClassification(['.github/actions/prepare-git-compatibility/action.yml'], { git_compatibility: true }) + // The contract pins the local-main fast-forward's exact arguments. + expectClassification(['src/shared/worktree/local-base-branch-fast-forward.ts'], { + git_compatibility: true, + package: true, + package_windows: true + }) }) it('runs the Codex index-heal contract only when the heal or its transport changes', () => { @@ -547,7 +553,7 @@ describe('PR Checks skip wiring', () => { expect(prWorkflow.jobs.code_paths.outputs.should_run).toBe( '${{ steps.filter.outputs.should_run }}' ) - for (const jobName of ['native_cache_changed', ...expensiveJobs]) { + for (const jobName of expensiveJobs) { expect(prWorkflow.jobs.code_paths.outputs[jobName], jobName).toBe( `\${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.${jobName} }}` ) @@ -602,31 +608,13 @@ describe('PR Checks skip wiring', () => { `needs.code_paths.outputs.${jobName} == 'true'` ) } - expect(prWorkflow.jobs.test.needs).toEqual([ - 'code_paths', - 'unit_plan', - 'test_native_cache', - 'static_analysis', - 'typecheck' - ]) - // Planning is deliberately NOT behind the static-analysis gate: it consumes nothing those - // jobs produce, so gating it only made the shards queue behind it. It still has to succeed - // before the shards run, or the matrix would expand from an empty assignment. - expect(prWorkflow.jobs.unit_plan.needs).toEqual(['code_paths']) - expect(prWorkflow.jobs.unit_plan.if).toBe("needs.code_paths.outputs.test == 'true'") - expect(prWorkflow.jobs.test.if).toContain("needs.unit_plan.result == 'success'") + expect(prWorkflow.jobs.test.needs).toEqual(['code_paths', 'static_analysis', 'typecheck']) + expect(prWorkflow.jobs.test.if).toContain("needs.static_analysis.result == 'success'") + expect(prWorkflow.jobs.test.if).toContain("needs.typecheck.result == 'success'") expect(prWorkflow.jobs.test.if).toContain("needs.code_paths.outputs.test == 'true'") - expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'success'") - expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'skipped'") - expect(prWorkflow.jobs.test_native_cache.needs).toEqual(['code_paths']) - expect(prWorkflow.jobs.test_native_cache.if).toBe( - "needs.code_paths.outputs.native_cache_changed == 'true'" - ) - expect(prWorkflow.jobs.test_native_cache.strategy).toBeUndefined() - const primerInstall = prWorkflow.jobs.test_native_cache.steps.find( - (step) => step.uses === './.github/actions/install-node-dependencies' - ) - expect(primerInstall.with['node-version']).toBe('24') + expect(prWorkflow.jobs.test.with.shards).toBe('${{ needs.typecheck.outputs.shards }}') + expect(prWorkflow.jobs.unit_plan).toBeUndefined() + expect(prWorkflow.jobs.test_native_cache).toBeUndefined() }) it('skips e2e detection on docs-only PRs without dropping the draft gate', () => { diff --git a/config/scripts/pr-preflight-gates.test.mjs b/config/scripts/pr-preflight-gates.test.mjs new file mode 100644 index 000000000000..84345ee93604 --- /dev/null +++ b/config/scripts/pr-preflight-gates.test.mjs @@ -0,0 +1,66 @@ +import { readFileSync } from 'node:fs' +import { expect, it } from 'vitest' +import { parse } from 'yaml' +import { classifyPrJobs } from './pr-code-change-scope.mjs' + +const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) +const typecheck = workflow.jobs.typecheck +const steps = typecheck.steps +const compiler = steps.find((step) => step.run === 'pnpm run typecheck') +const plan = steps.find((step) => step.id === 'unit-plan') + +it('shares planning setup while keeping the heavy checks on separate runners', () => { + expect(workflow.jobs.unit_plan).toBeUndefined() + expect(workflow.jobs.test_native_cache).toBeUndefined() + expect(typecheck.needs).toEqual(['code_paths']) + expect(workflow.jobs.static_analysis.needs).toEqual(['code_paths']) + expect( + steps.filter((step) => step.uses === './.github/actions/install-node-dependencies') + ).toHaveLength(1) + expect(steps[0].with['fetch-depth']).toBeGreaterThanOrEqual(2) + expect(compiler.background).toBeUndefined() + expect(plan.background).toBe(true) + expect(plan.run).toBe('node config/scripts/ci-unit-plan.mjs') + expect(plan.env.ORCA_UNIT_SELECTION_MODE).toContain('vars.ORCA_UNIT_SELECTION_MODE') + expect(steps.indexOf(plan)).toBeLessThan(steps.indexOf(compiler)) + const installs = workflow.jobs.static_analysis.steps.filter( + (step) => step.uses === './.github/actions/install-node-dependencies' + ) + expect(installs).toHaveLength(2) + for (const install of installs) { + expect(install.with['native-runtime']).toBe('node') + expect(install.with['node-version']).toBe('24') + expect(install.with['persist-native-cache']).not.toBe('false') + } +}) + +it('requires compiler success and joined planning before publishing shards and admitting tests', () => { + const join = steps.findIndex((step) => step.wait === 'unit-plan') + const upload = steps.findIndex((step) => step.uses === 'actions/upload-artifact@v7') + expect(join).toBeGreaterThan(steps.indexOf(compiler)) + expect(upload).toBeGreaterThan(join) + expect(steps[upload]['continue-on-error']).toBeUndefined() + expect(steps[upload].with.name).toBe('unit-selection-attempt-${{ github.run_attempt }}') + expect(typecheck.outputs.shards).toBe('${{ steps.unit-plan.outputs.shards }}') + expect(workflow.jobs.test.with.shards).toBe('${{ needs.typecheck.outputs.shards }}') + for (const job of ['test', 'package', 'package_windows']) { + expect(workflow.jobs[job].needs).toEqual(['code_paths', 'static_analysis', 'typecheck']) + } + expect(workflow.jobs.test.if).toContain("needs.static_analysis.result == 'success'") + expect(workflow.jobs.test.if).toContain("needs.typecheck.result == 'success'") + expect(workflow.jobs.verify.needs).toContain('static_analysis') + expect(workflow.jobs.verify.needs).toContain('typecheck') +}) + +it.each( + [['README.md'], ['mobile/src/App.tsx'], ['cloud/package.json'], ['src/main/index.ts'], []].map( + (changed) => ({ changed }) + ) +)('keeps desktop typechecking and planning off unrelated paths: $changed', ({ changed }) => { + const scope = classifyPrJobs(changed) + expect(typecheck.if).toBe("needs.code_paths.outputs.typecheck == 'true'") + expect(scope.test).toBe(scope.typecheck) + if (scope.test) { + expect(scope.static_analysis).toBe(true) + } +}) diff --git a/config/scripts/pr-ready-check-reuse.test.mjs b/config/scripts/pr-ready-check-reuse.test.mjs index 1d64542a6144..19fb24072334 100644 --- a/config/scripts/pr-ready-check-reuse.test.mjs +++ b/config/scripts/pr-ready-check-reuse.test.mjs @@ -139,7 +139,7 @@ describe('ready-for-review required check reuse', () => { expect(detector.steps[0].with['sparse-checkout']).toContain( '/config/scripts/pr-ready-check-reuse.mjs' ) - for (const job of ['native_cache_changed', ...PR_CHECK_JOBS]) { + for (const job of PR_CHECK_JOBS) { expect(detector.outputs[job]).toBe( `\${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.${job} }}` ) diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 04bbf5d489ab..d93b2901bb7f 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -69,13 +69,13 @@ describe('PR workflow parallelism', () => { expect(workflow.permissions).toEqual({ contents: 'read' }) }) - it('runs all eight shards on ARM for PRs and both Node versions on x86 daily', () => { + it('runs PR shards on ARM and both Node versions on x86 daily', () => { const sharedTest = unitTestWorkflow.jobs.test const testStep = sharedTest.steps.find((step) => step.name === 'Test shard') const installStep = sharedTest.steps.find( (step) => step.uses === './.github/actions/install-node-dependencies' ) - const primerInstall = workflow.jobs.test_native_cache.steps.find( + const staticInstall = workflow.jobs.static_analysis.steps.find( (step) => step.uses === './.github/actions/install-node-dependencies' ) const nodeNextPrimerInstall = nodeNextWorkflow.jobs.test_native_cache.steps.find( @@ -87,7 +87,7 @@ describe('PR workflow parallelism', () => { expect(nodeNextWorkflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml') expect(JSON.parse(nodeNextWorkflow.jobs.test.with.node_versions)).toEqual(['24', '26']) expect(workflow.jobs.test.with.runner).toBe('ubuntu-24.04-arm') - expect(workflow.jobs.test_native_cache['runs-on']).toBe('ubuntu-24.04-arm') + expect(workflow.jobs.static_analysis['runs-on']).toBe('ubuntu-24.04-arm') expect(sharedTest['runs-on']).toBe('${{ inputs.runner }}') expect(unitTestWorkflow.on.workflow_call.inputs.runner.default).toBe('ubuntu-latest') expect(nodeNextWorkflow.jobs.test.with.runner).toBeUndefined() @@ -105,8 +105,7 @@ describe('PR workflow parallelism', () => { expect(nodeNextWorkflow.on.workflow_dispatch).toBeNull() expect(sharedTest.strategy.matrix.node).toBe('${{ fromJSON(inputs.node_versions) }}') expect(sharedTest.strategy.matrix.shard).toBe('${{ fromJSON(inputs.shards) }}') - // The shard matrix no longer needs an in-workflow plan job: planning moved to - // unit-plan.yml so it can run before the static-analysis gate clears. + // PR planning overlaps typecheck; the daily suite keeps its separate planner. expect(sharedTest.needs).toBeUndefined() expect(unitTestWorkflow.jobs.plan).toBeUndefined() expect(unitTestWorkflow.on.workflow_call.inputs.shards.required).toBe(true) @@ -116,9 +115,10 @@ describe('PR workflow parallelism', () => { for (const testFile of nativeShellContractFiles) { expect(UNIT_EXCLUDE).toContain(testFile) } - expect(primerInstall.with['native-runtime']).toBe('node') - expect(primerInstall.with['node-version']).toBe('24') - expect(workflow.jobs.test.needs).toContain('test_native_cache') + expect(staticInstall.with['native-runtime']).toBe('node') + expect(staticInstall.with['node-version']).toBe('24') + expect(workflow.jobs.test.needs).toContain('static_analysis') + expect(workflow.jobs.test_native_cache).toBeUndefined() expect(nodeNextPrimerInstall.with['native-runtime']).toBe('node') expect(nodeNextPrimerInstall.with['node-version']).toBe('${{ matrix.node }}') expect(nodeNextWorkflow.jobs.test.needs).toEqual(['test_native_cache', 'unit_plan']) diff --git a/config/scripts/profile-state-worker-smoke.mjs b/config/scripts/profile-state-worker-smoke.mjs index 81ff1b5eaf13..9071417145dd 100644 --- a/config/scripts/profile-state-worker-smoke.mjs +++ b/config/scripts/profile-state-worker-smoke.mjs @@ -6,12 +6,12 @@ import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { Worker } from 'node:worker_threads' -import { runProcessSync } from './script-child-process.mjs' +import { describeProcessFailure, runProcessSync } from './script-child-process.mjs' import { ORCAD_PROFILE_PREFLIGHT_FLAG, parseOrcadProfilePreflight } from '../../src/shared/orcad-profile-preflight.ts' -import { ORCAD_BUN_VERSION } from '../../src/shared/orcad-bun-runtime.ts' +import { NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts' async function initializeFixture(directory, databasePath, profileId) { const fixture = join(directory, 'initialize.cjs') @@ -94,9 +94,14 @@ export async function smokeProfileStateWorkers(outDir, { timeoutMs = 30_000, run maxOutputBytes: 64 * 1024 }) if (result.code !== 0 || result.timedOut || result.outputTruncated) { - throw new Error(`Packaged profile runtime preflight failed: ${result.stderr}`) + throw new Error( + `Packaged profile runtime preflight failed: ${describeProcessFailure(result)}` + ) } - parseOrcadProfilePreflight(result.stdout, nonce, ORCAD_BUN_VERSION) + parseOrcadProfilePreflight(result.stdout, nonce, { + runtime: 'node', + runtimeVersion: NODE_RUNTIME_PIN.version + }) return } const directory = mkdtempSync(join(tmpdir(), 'orca-profile-worker-smoke-')) diff --git a/config/scripts/run-bun-profile-tests.mjs b/config/scripts/run-bun-profile-tests.mjs deleted file mode 100644 index debd895675b0..000000000000 --- a/config/scripts/run-bun-profile-tests.mjs +++ /dev/null @@ -1,74 +0,0 @@ -import { join, resolve } from 'node:path' -import { randomUUID } from 'node:crypto' -import { readFileSync } from 'node:fs' -import { - ORCAD_VERSION_FILENAME, - orcadBunRuntimeFilename -} from '../../src/shared/orcad-artifacts.ts' -import { ORCAD_BUN_VERSION } from '../../src/shared/orcad-bun-runtime.ts' -import { - ORCAD_PROFILE_PREFLIGHT_FLAG, - parseOrcadProfilePreflight -} from '../../src/shared/orcad-profile-preflight.ts' -import { currentTarget } from './build-orcad-bun.mjs' -import { runProcessSync } from './script-child-process.mjs' -import { bunProfileTestPaths } from './bun-profile-test-paths.mjs' - -const root = resolve(import.meta.dirname, '../..') -const target = currentTarget() -const artifact = process.argv.includes('--artifact') -const testArgs = process.argv.slice(2).filter((arg) => arg !== '--artifact') -const runtimeDir = artifact - ? join(root, 'out', 'orcad') - : join(root, 'out', '.bun-profile-test-runtime', target) -const runtimePath = join(runtimeDir, orcadBunRuntimeFilename(target)) -const env = { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', BUN_EXECUTABLE: runtimePath } - -function run(program, args) { - const result = runProcessSync({ - program, - args, - cwd: root, - env, - stdio: 'inherit', - timeoutMs: null - }) - if (result.code !== 0) { - process.exit(result.code ?? 1) - } -} - -if (artifact) { - const nonce = randomUUID() - const result = runProcessSync({ - program: runtimePath, - args: [join(runtimeDir, 'orcad.js'), ORCAD_PROFILE_PREFLIGHT_FLAG, nonce], - cwd: root, - env, - timeoutMs: 90_000 - }) - if (result.code !== 0 || result.timedOut || result.outputTruncated) { - throw new Error(`Bundled runtime readiness failed: ${result.stderr}`) - } - const response = parseOrcadProfilePreflight( - result.stdout, - nonce, - ORCAD_BUN_VERSION, - readFileSync(join(runtimeDir, ORCAD_VERSION_FILENAME), 'utf8').trim() - ) - process.stdout.write(`${JSON.stringify({ target, ...response })}\n`) -} else { - run(process.execPath, [ - join(root, 'config/scripts/build-orcad-bun.mjs'), - '--runtime-only', - '--out-dir', - runtimeDir - ]) -} -run(runtimePath, [ - join(root, 'node_modules/vitest/vitest.mjs'), - 'run', - '--config', - 'config/vitest.config.ts', - ...(testArgs.length > 0 ? testArgs : bunProfileTestPaths({ artifact })) -]) diff --git a/config/scripts/run-node-server-tests.mjs b/config/scripts/run-node-server-tests.mjs new file mode 100644 index 000000000000..2927f77277a2 --- /dev/null +++ b/config/scripts/run-node-server-tests.mjs @@ -0,0 +1,92 @@ +// Runs the headless-server suites under the pinned Node (design D4/D4a), not the host's. +import { join, resolve } from 'node:path' +import { randomUUID } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { ORCAD_VERSION_FILENAME } from '../../src/shared/orcad-artifacts.ts' +import { NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts' +import { + ORCAD_PROFILE_PREFLIGHT_FLAG, + parseOrcadProfilePreflight +} from '../../src/shared/orcad-profile-preflight.ts' +import { packagedNodeRuntimePath } from './build-orcad-node.mjs' +import { ensurePinnedNodeExecutable } from './pinned-node-downloads.mjs' +import { currentTarget } from './server-build-target.mjs' +import { describeProcessFailure, runProcessSync } from './script-child-process.mjs' +import { + CROSS_RUNTIME_TEST_PATHS, + nodeServerTestPaths, + REQUIRED_TEST_INPUTS_ENV +} from './node-server-test-paths.mjs' + +const root = resolve(import.meta.dirname, '../..') +const target = currentTarget() +const artifact = process.argv.includes('--artifact') +// Lanes that provide Bun 1.4.2 and the last Bun orcad slot (design D7 upgrade and rollback). +const crossRuntime = process.argv.includes('--cross-runtime') +const testArgs = process.argv + .slice(2) + .filter((arg) => arg !== '--artifact' && arg !== '--cross-runtime') +const packageDir = join(root, 'out', 'orcad') +const runtimePath = artifact + ? packagedNodeRuntimePath(packageDir, target) + : await ensurePinnedNodeExecutable({ target }) +// Tests that launch the packaged runtime themselves find it here. A lane's named inputs are +// required: their tests fail on a missing input instead of passing as a skip. +const env = { + ...process.env, + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_PINNED_NODE: runtimePath, + [REQUIRED_TEST_INPUTS_ENV]: [artifact && 'artifact', crossRuntime && 'cross-runtime'] + .filter(Boolean) + .join(',') +} + +function run(program, args) { + const result = runProcessSync({ + program, + args, + cwd: root, + env, + stdio: 'inherit', + timeoutMs: null + }) + if (result.code !== 0) { + process.exit(result.code ?? 1) + } +} + +if (artifact) { + const nonce = randomUUID() + const result = runProcessSync({ + program: runtimePath, + args: [join(packageDir, 'orcad.js'), ORCAD_PROFILE_PREFLIGHT_FLAG, nonce], + cwd: root, + env, + timeoutMs: 90_000 + }) + if (result.code !== 0 || result.timedOut || result.outputTruncated) { + throw new Error(`Bundled runtime readiness failed: ${describeProcessFailure(result)}`) + } + const response = parseOrcadProfilePreflight( + result.stdout, + nonce, + { runtime: 'node', runtimeVersion: NODE_RUNTIME_PIN.version }, + readFileSync(join(packageDir, ORCAD_VERSION_FILENAME), 'utf8').trim() + ) + process.stdout.write(`${JSON.stringify({ target, ...response })}\n`) +} +run(runtimePath, [ + join(root, 'node_modules/vitest/vitest.mjs'), + 'run', + '--config', + 'config/vitest.config.ts', + ...(testArgs.length > 0 ? testArgs : defaultTestArgs()) +]) + +function defaultTestArgs() { + return [ + ...nodeServerTestPaths({ artifact, crossRuntime }), + // A directory selector would otherwise pull them into lanes that lack their inputs. + ...(crossRuntime ? [] : CROSS_RUNTIME_TEST_PATHS.flatMap((path) => ['--exclude', path])) + ] +} diff --git a/config/scripts/script-child-process.mjs b/config/scripts/script-child-process.mjs index fca512c33241..5486a78ba8d7 100644 --- a/config/scripts/script-child-process.mjs +++ b/config/scripts/script-child-process.mjs @@ -33,3 +33,15 @@ try { export const runProcessSync = implementation.runProcessSync export const spawnProcess = implementation.spawnProcess + +/** Why a failed child failed, for CI logs where an empty stderr alone says nothing. */ +export function describeProcessFailure(result) { + const detail = [ + `code=${result.code ?? 'none'}`, + `signal=${result.signal ?? 'none'}`, + ...(result.timedOut ? ['timed out'] : []), + ...(result.outputTruncated ? ['output truncated'] : []) + ].join(' ') + const stream = (name, text) => (text?.trim() ? `\n${name}:\n${text.trim().slice(-4000)}` : '') + return `${detail}${stream('stdout', result.stdout)}${stream('stderr', result.stderr)}` +} diff --git a/config/scripts/script-child-process.test.mjs b/config/scripts/script-child-process.test.mjs new file mode 100644 index 000000000000..161124037c14 --- /dev/null +++ b/config/scripts/script-child-process.test.mjs @@ -0,0 +1,29 @@ +import { expect, it } from 'vitest' +import { describeProcessFailure } from './script-child-process.mjs' + +it('names a timeout and both streams when stderr is empty', () => { + expect( + describeProcessFailure({ + code: null, + signal: 'SIGTERM', + stdout: '{"type":"orca_profile_state_ready"}\r\n', + stderr: '', + timedOut: true, + outputTruncated: false + }) + ).toBe('code=none signal=SIGTERM timed out\nstdout:\n{"type":"orca_profile_state_ready"}') +}) + +it('keeps the tail of long output', () => { + const failure = describeProcessFailure({ + code: 1, + signal: null, + stdout: '', + stderr: `${'x'.repeat(5000)}END`, + timedOut: false, + outputTruncated: true + }) + expect(failure.startsWith('code=1 signal=none output truncated\nstderr:\n')).toBe(true) + expect(failure.endsWith('END')).toBe(true) + expect(failure.length).toBeLessThan(4100) +}) diff --git a/config/scripts/server-build-target.mjs b/config/scripts/server-build-target.mjs new file mode 100644 index 000000000000..b6125d9d8139 --- /dev/null +++ b/config/scripts/server-build-target.mjs @@ -0,0 +1,14 @@ +/** The build host's own server target (src/shared/node-runtime-pin.ts SERVER_TARGETS). */ +export function currentTarget() { + if (process.platform === 'darwin') { + return `darwin-${process.arch}` + } + if (process.platform === 'win32') { + return `win32-${process.arch}` + } + if (process.platform !== 'linux') { + throw new Error(`Unsupported server platform: ${process.platform}`) + } + const glibc = process.report?.getReport()?.header?.glibcVersionRuntime + return `linux-${process.arch}-${glibc ? 'glibc' : 'musl'}` +} diff --git a/config/scripts/stable-release-tags.mjs b/config/scripts/stable-release-tags.mjs new file mode 100644 index 000000000000..2650f23eaa8e --- /dev/null +++ b/config/scripts/stable-release-tags.mjs @@ -0,0 +1,30 @@ +// Stable desktop release tags (vX.Y.Z); shared by the cross-version harness and the R1 protocol gate. +export const STABLE_DESKTOP_RELEASE_TAG = /^v\d+\.\d+\.\d+$/ + +export function compareReleaseTags(a, b) { + const parts = (tag) => + tag + .replace(/^v/, '') + .split('.') + .map((part) => Number.parseInt(part, 10)) + .map((value) => (Number.isFinite(value) ? value : 0)) + const left = parts(a) + const right = parts(b) + for (let index = 0; index < Math.max(left.length, right.length); index++) { + const diff = (left[index] ?? 0) - (right[index] ?? 0) + if (diff !== 0) { + return diff + } + } + return 0 +} + +/** @param {string[]} tags @returns {string | null} */ +export function selectLatestStableReleaseTag(tags) { + return ( + tags + .filter((tag) => STABLE_DESKTOP_RELEASE_TAG.test(tag)) + .sort(compareReleaseTags) + .at(-1) ?? null + ) +} diff --git a/config/scripts/update-node-runtime-pin.mjs b/config/scripts/update-node-runtime-pin.mjs new file mode 100644 index 000000000000..76011ec15509 --- /dev/null +++ b/config/scripts/update-node-runtime-pin.mjs @@ -0,0 +1,382 @@ +#!/usr/bin/env node +// Regenerates the pinned asset table in src/shared/node-runtime-pin.ts. Needs network; CI never runs it. +// Usage: node config/scripts/update-node-runtime-pin.mjs --version 24.21.0 [--work-dir DIR] [--keyring FILE] + +import { createHash } from 'node:crypto' +import { + createReadStream, + createWriteStream, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { Readable } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import { pathToFileURL } from 'node:url' +import { + COMPAT_SERVER_TARGETS, + SERVER_TARGETS, + nodeRuntimeExecutablePath, + nodeRuntimeReleaseUrl +} from '../../src/shared/node-runtime-pin.ts' +import { currentTarget } from './server-build-target.mjs' +import { nodeDistArchiveName, windowsImportLibFile } from './node-dist-archive-name.mjs' +import { runProcessSync } from './script-child-process.mjs' +import { getZipExtractorCommand } from './zip-extractor-command.mjs' + +const root = resolve(import.meta.dirname, '../..') +const PIN_FILE = join(root, 'src/shared/node-runtime-pin.ts') +const GENERATED_BEGIN = '// @generated-begin by config/scripts/update-node-runtime-pin.mjs' +const GENERATED_END = '// @generated-end' +const RELEASE_KEYRING_URL = + 'https://raw.githubusercontent.com/nodejs/release-keys/HEAD/gpg/pubring.kbx' + +const WINDOWS_TARGETS = SERVER_TARGETS.filter((target) => target.startsWith('win32-')) + +/** SHASUMS256.txt is signature-verified before this runs, so its node.lib hashes are trusted as-is. */ +export function pinWindowsImportLibs(officialHashes) { + const libs = {} + for (const target of WINDOWS_TARGETS) { + const file = windowsImportLibFile(target) + const sha256 = officialHashes.get(file) + if (!sha256) { + throw new Error(`SHASUMS256.txt lists no ${file}`) + } + libs[target] = { file, sha256 } + } + return libs +} + +export function parseShasums(text) { + const hashes = new Map() + for (const line of text.split('\n')) { + const match = /^([0-9a-f]{64}) {2}(\S+)$/.exec(line.trim()) + if (match) { + hashes.set(match[2], match[1]) + } + } + return hashes +} + +/** Official builds win over unofficial ones when both publish the same archive. */ +export function selectAssetSource(archive, officialHashes, unofficialHashes) { + if (officialHashes.has(archive)) { + return { source: 'official', archiveSha256: officialHashes.get(archive) } + } + if (unofficialHashes.has(archive)) { + return { source: 'unofficial', archiveSha256: unofficialHashes.get(archive) } + } + return null +} + +export function parseNodeApiVersion(nodeVersionHeader) { + const match = /#define NODE_API_SUPPORTED_VERSION_MAX (\d+)/.exec(nodeVersionHeader) + if (!match) { + throw new Error('node_version.h has no NODE_API_SUPPORTED_VERSION_MAX') + } + return Number(match[1]) +} + +function renderAssetTable(name, type, targets, assets) { + const lines = [`export const ${name}: Record<${type}, NodeRuntimeAsset> = {`] + targets.forEach((target, index) => { + const asset = assets[target] + lines.push( + ` '${target}': {`, + ` source: '${asset.source}',`, + ` archive: '${asset.archive}',`, + ` archiveSha256: '${asset.archiveSha256}',`, + ` executableSha256: '${asset.executableSha256}',`, + ` executableSize: ${asset.executableSize}`, + index === targets.length - 1 ? ' }' : ' },' + ) + }) + lines.push('}') + return lines +} + +export function renderGeneratedBlock(pin, assets, compatAssets) { + const lines = [ + GENERATED_BEGIN, + 'export const NODE_RUNTIME_PIN: NodeRuntimePin = {', + ` version: '${pin.version}',`, + ` electron: '${pin.electron}',`, + ` napi: ${pin.napi},`, + ' headers: {', + ` file: '${pin.headers.file}',`, + ` sha256: '${pin.headers.sha256}'`, + ' },', + ' windowsImportLibs: {', + ...WINDOWS_TARGETS.flatMap((target, index) => [ + ` '${target}': {`, + ` file: '${pin.windowsImportLibs[target].file}',`, + ` sha256: '${pin.windowsImportLibs[target].sha256}'`, + index === WINDOWS_TARGETS.length - 1 ? ' }' : ' },' + ]), + ' }', + '}', + '', + ...renderAssetTable('NODE_RUNTIME_ASSETS', 'ServerTarget', SERVER_TARGETS, assets), + '', + ...renderAssetTable( + 'NODE_RUNTIME_COMPAT_ASSETS', + 'CompatServerTarget', + COMPAT_SERVER_TARGETS, + compatAssets + ), + GENERATED_END + ] + return lines.join('\n') +} + +export function replaceGeneratedBlock(source, block) { + const begin = source.indexOf(GENERATED_BEGIN) + const end = source.indexOf(GENERATED_END) + if (begin === -1 || end === -1 || end < begin) { + throw new Error('node-runtime-pin.ts is missing its @generated markers') + } + return source.slice(0, begin) + block + source.slice(end + GENERATED_END.length) +} + +function argument(name) { + const index = process.argv.indexOf(name) + return index === -1 ? null : process.argv[index + 1] +} + +async function fetchText(url) { + const response = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(60_000) }) + if (!response.ok) { + await response.body?.cancel() + throw new Error(`GET ${url} failed: ${response.status} ${response.statusText}`) + } + return response.text() +} + +export async function download(url, destination) { + const response = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(600_000) }) + if (!response.ok || !response.body) { + await response.body?.cancel() + throw new Error(`GET ${url} failed: ${response.status} ${response.statusText}`) + } + await pipeline(Readable.fromWeb(response.body), createWriteStream(destination)) +} + +export async function sha256File(path) { + const hash = createHash('sha256') + await pipeline(createReadStream(path), hash) + return hash.digest('hex') +} + +function run(program, args) { + const result = runProcessSync({ program, args, timeoutMs: 300_000 }) + if (result.code !== 0) { + throw new Error( + `${program} ${args.join(' ')} exited ${result.code}: ${result.stderr || result.stdout}` + ) + } + return result.stdout +} + +function tarProgram() { + return process.platform === 'win32' + ? join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe') + : 'tar' +} + +export function extract(archivePath, destination, member) { + mkdirSync(destination, { recursive: true }) + if (archivePath.endsWith('.zip')) { + const command = getZipExtractorCommand(archivePath, destination) + run(command.file, command.args) + return + } + run(tarProgram(), ['-xzf', archivePath, '-C', destination, member]) +} + +function gpgAvailable() { + try { + return runProcessSync({ program: 'gpg', args: ['--version'], timeoutMs: 10_000 }).code === 0 + } catch { + return false + } +} + +async function verifyOfficialShasums(version, workDir, shasumsPath) { + if (!gpgAvailable()) { + console.warn( + '\n!!! WARNING: gpg is not installed, so SHASUMS256.txt was NOT signature-verified.\n' + + '!!! Its hashes are trusted over TLS only. Install gpg and rerun before committing a pin.\n' + ) + return false + } + const signaturePath = join(workDir, 'SHASUMS256.txt.sig') + await download(nodeRuntimeReleaseUrl('official', 'SHASUMS256.txt.sig', version), signaturePath) + let keyring = argument('--keyring') + if (!keyring) { + keyring = join(workDir, 'nodejs-release-keys.kbx') + try { + await download(RELEASE_KEYRING_URL, keyring) + } catch (error) { + console.warn( + `\n!!! WARNING: could not fetch Node release keys (${error.message}); ` + + 'SHASUMS256.txt was NOT signature-verified.\n' + ) + return false + } + } + const gnupgHome = join(workDir, 'gnupg') + mkdirSync(gnupgHome, { recursive: true, mode: 0o700 }) + const result = runProcessSync({ + program: 'gpg', + args: [ + '--homedir', + gnupgHome, + '--no-default-keyring', + '--keyring', + resolve(keyring), + '--verify', + signaturePath, + shasumsPath + ], + timeoutMs: 60_000 + }) + if (result.code !== 0) { + throw new Error(`SHASUMS256.txt signature verification failed:\n${result.stderr}`) + } + console.log('Verified SHASUMS256.txt signature against the Node.js release keys.') + return true +} + +async function pinTarget({ version, napi, target, workDir, officialHashes, unofficialHashes }) { + const archive = nodeDistArchiveName(version, target) + const selected = selectAssetSource(archive, officialHashes, unofficialHashes) + if (!selected) { + // Why fail: a bump must not ship with a target that has no runtime (design D1 risks). + throw new Error(`No published ${archive} for ${target}; the pin cannot move to ${version}`) + } + const archivePath = join(workDir, archive) + await download(nodeRuntimeReleaseUrl(selected.source, archive, version), archivePath) + const actual = await sha256File(archivePath) + if (actual !== selected.archiveSha256) { + throw new Error(`${archive} hash ${actual} does not match SHASUMS ${selected.archiveSha256}`) + } + const member = nodeRuntimeExecutablePath(target, archive) + const extracted = join(workDir, `extract-${target}`) + extract(archivePath, extracted, member) + const executablePath = join(extracted, member) + const asset = { + source: selected.source, + archive, + archiveSha256: selected.archiveSha256, + executableSha256: await sha256File(executablePath), + executableSize: statSync(executablePath).size + } + if (target === currentTarget()) { + const reported = run(executablePath, [ + '-p', + '`${process.version} ${process.versions.napi}`' + ]).trim() + if (reported !== `v${version} ${napi}`) { + throw new Error(`${member} reports ${reported}, expected v${version} ${napi}`) + } + } + rmSync(extracted, { recursive: true, force: true }) + rmSync(archivePath, { force: true }) + console.log(`${target}: ${asset.source} ${archive} (${asset.executableSize} bytes)`) + return asset +} + +async function pinHeaders(version, workDir, officialHashes) { + const file = `node-v${version}-headers.tar.gz` + const sha256 = officialHashes.get(file) + if (!sha256) { + throw new Error(`SHASUMS256.txt lists no ${file}`) + } + const archivePath = join(workDir, file) + await download(nodeRuntimeReleaseUrl('official', file, version), archivePath) + const actual = await sha256File(archivePath) + if (actual !== sha256) { + throw new Error(`${file} hash ${actual} does not match SHASUMS ${sha256}`) + } + const member = `node-v${version}/include/node/node_version.h` + const extracted = join(workDir, 'extract-headers') + extract(archivePath, extracted, member) + const napi = parseNodeApiVersion(readFileSync(join(extracted, member), 'utf8')) + return { headers: { file, sha256 }, napi } +} + +function pinnedElectronVersion() { + const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) + const declared = pkg.devDependencies?.electron ?? pkg.dependencies?.electron + if (!/^\d+\.\d+\.\d+$/.test(declared ?? '')) { + throw new Error(`package.json must pin an exact electron version, found ${declared}`) + } + return declared +} + +async function main() { + const version = argument('--version') + if (!/^\d+\.\d+\.\d+$/.test(version ?? '')) { + throw new Error('Usage: update-node-runtime-pin.mjs --version ') + } + const workParent = argument('--work-dir') ?? tmpdir() + mkdirSync(workParent, { recursive: true }) + const workDir = mkdtempSync(join(workParent, 'orca-node-runtime-pin-')) + try { + const shasumsPath = join(workDir, 'SHASUMS256.txt') + await download(nodeRuntimeReleaseUrl('official', 'SHASUMS256.txt', version), shasumsPath) + await verifyOfficialShasums(version, workDir, shasumsPath) + const officialHashes = parseShasums(readFileSync(shasumsPath, 'utf8')) + const unofficialHashes = parseShasums( + await fetchText(nodeRuntimeReleaseUrl('unofficial', 'SHASUMS256.txt', version)) + ) + const { headers, napi } = await pinHeaders(version, workDir, officialHashes) + const assets = {} + for (const target of SERVER_TARGETS) { + assets[target] = await pinTarget({ + version, + napi, + target, + workDir, + officialHashes, + unofficialHashes + }) + } + // Why unofficial only: nodejs.org publishes no glibc 2.17 build; selectAssetSource finds it. + const compatAssets = {} + for (const target of COMPAT_SERVER_TARGETS) { + compatAssets[target] = await pinTarget({ + version, + napi, + target, + workDir, + officialHashes, + unofficialHashes + }) + } + const pin = { + version, + electron: pinnedElectronVersion(), + napi, + headers, + windowsImportLibs: pinWindowsImportLibs(officialHashes) + } + const source = readFileSync(PIN_FILE, 'utf8') + writeFileSync( + PIN_FILE, + replaceGeneratedBlock(source, renderGeneratedBlock(pin, assets, compatAssets)) + ) + console.log(`Wrote ${PIN_FILE}. Run check-node-runtime-pin.mjs before committing.`) + } finally { + rmSync(workDir, { recursive: true, force: true }) + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main() +} diff --git a/config/scripts/update-node-runtime-pin.test.mjs b/config/scripts/update-node-runtime-pin.test.mjs new file mode 100644 index 000000000000..5e9e4243d96c --- /dev/null +++ b/config/scripts/update-node-runtime-pin.test.mjs @@ -0,0 +1,148 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { + COMPAT_SERVER_TARGETS, + NODE_RUNTIME_ASSETS, + NODE_RUNTIME_COMPAT_ASSETS, + NODE_RUNTIME_PIN, + SERVER_TARGETS, + nodeRuntimeAsset, + nodeRuntimeExecutablePath +} from '../../src/shared/node-runtime-pin.ts' +import { nodeDistArchiveName } from './node-dist-archive-name.mjs' +import { + parseNodeApiVersion, + parseShasums, + pinWindowsImportLibs, + renderGeneratedBlock, + replaceGeneratedBlock, + selectAssetSource +} from './update-node-runtime-pin.mjs' + +const pinFile = path.resolve(import.meta.dirname, '../../src/shared/node-runtime-pin.ts') +const HASH_A = 'a'.repeat(64) +const HASH_B = 'b'.repeat(64) + +describe('pinWindowsImportLibs', () => { + it('reads each Windows node.lib hash from SHASUMS and refuses a missing one', () => { + const hashes = new Map([ + ['win-x64/node.lib', HASH_A], + ['win-arm64/node.lib', HASH_B] + ]) + expect(pinWindowsImportLibs(hashes)).toEqual({ + 'win32-arm64': { file: 'win-arm64/node.lib', sha256: HASH_B }, + 'win32-x64': { file: 'win-x64/node.lib', sha256: HASH_A } + }) + hashes.delete('win-arm64/node.lib') + expect(() => pinWindowsImportLibs(hashes)).toThrow('SHASUMS256.txt lists no win-arm64/node.lib') + }) +}) + +describe('nodeDistArchiveName', () => { + it('uses nodejs.org platform names and zip only on Windows', () => { + expect(nodeDistArchiveName('24.21.0', 'linux-x64-glibc')).toBe('node-v24.21.0-linux-x64.tar.gz') + expect(nodeDistArchiveName('24.21.0', 'linux-arm64-musl')).toBe( + 'node-v24.21.0-linux-arm64-musl.tar.gz' + ) + expect(nodeDistArchiveName('24.21.0', 'win32-arm64')).toBe('node-v24.21.0-win-arm64.zip') + }) + + it('names the unofficial glibc 2.17 build for the compat target', () => { + expect(nodeDistArchiveName('24.21.0', 'linux-x64-glibc217')).toBe( + 'node-v24.21.0-linux-x64-glibc-217.tar.gz' + ) + }) + + it('covers every server and compat target', () => { + for (const target of [...SERVER_TARGETS, ...COMPAT_SERVER_TARGETS]) { + expect(nodeDistArchiveName('24.21.0', target)).not.toContain('undefined') + } + }) +}) + +describe('nodeRuntimeExecutablePath', () => { + it('points at bin/node on POSIX and node.exe on Windows', () => { + expect(nodeRuntimeExecutablePath('darwin-arm64', 'node-v24.21.0-darwin-arm64.tar.gz')).toBe( + 'node-v24.21.0-darwin-arm64/bin/node' + ) + expect(nodeRuntimeExecutablePath('win32-x64', 'node-v24.21.0-win-x64.zip')).toBe( + 'node-v24.21.0-win-x64/node.exe' + ) + }) +}) + +describe('parseShasums and selectAssetSource', () => { + const official = parseShasums( + `${HASH_A} node-v24.21.0-linux-x64-musl.tar.gz\nnot a hash line\n${HASH_A} node-v24.21.0-linux-x64.tar.gz\n` + ) + const unofficial = parseShasums( + `${HASH_B} node-v24.21.0-linux-x64-musl.tar.gz\n${HASH_B} node-v24.21.0-linux-arm64-musl.tar.gz\n` + ) + + it('prefers the official build when both publish an archive', () => { + expect(selectAssetSource('node-v24.21.0-linux-x64-musl.tar.gz', official, unofficial)).toEqual({ + source: 'official', + archiveSha256: HASH_A + }) + }) + + it('falls back to unofficial builds and reports a missing archive as null', () => { + expect( + selectAssetSource('node-v24.21.0-linux-arm64-musl.tar.gz', official, unofficial) + ).toEqual({ source: 'unofficial', archiveSha256: HASH_B }) + expect(selectAssetSource('node-v24.21.0-aix-ppc64.tar.gz', official, unofficial)).toBeNull() + }) +}) + +describe('parseNodeApiVersion', () => { + it('reads the highest supported N-API version from node_version.h', () => { + expect( + parseNodeApiVersion( + '#define NODE_API_SUPPORTED_VERSION_MAX 10\n#define NODE_API_SUPPORTED_VERSION_MIN 1\n' + ) + ).toBe(10) + expect(() => parseNodeApiVersion('#define NODE_MAJOR_VERSION 24')).toThrow() + }) +}) + +describe('compat runtime targets', () => { + it('stay out of the default target list', () => { + for (const target of COMPAT_SERVER_TARGETS) { + expect(SERVER_TARGETS).not.toContain(target) + expect(NODE_RUNTIME_ASSETS).not.toHaveProperty(target) + } + }) + + it('resolve through nodeRuntimeAsset beside the default targets', () => { + expect(nodeRuntimeAsset('linux-x64-glibc217')).toBe( + NODE_RUNTIME_COMPAT_ASSETS['linux-x64-glibc217'] + ) + expect(nodeRuntimeAsset('linux-x64-glibc')).toBe(NODE_RUNTIME_ASSETS['linux-x64-glibc']) + expect(nodeRuntimeAsset('linux-riscv64-glibc')).toBeUndefined() + expect(nodeRuntimeAsset('toString')).toBeUndefined() + }) + + it('pin the unofficial build at its extracted bin/node', () => { + const asset = NODE_RUNTIME_COMPAT_ASSETS['linux-x64-glibc217'] + expect(asset.source).toBe('unofficial') + expect(nodeRuntimeExecutablePath('linux-x64-glibc217', asset.archive)).toBe( + `node-v${NODE_RUNTIME_PIN.version}-linux-x64-glibc-217/bin/node` + ) + }) +}) + +describe('generated block', () => { + it('reproduces the committed table byte for byte', () => { + const source = readFileSync(pinFile, 'utf8') + const regenerated = replaceGeneratedBlock( + source, + renderGeneratedBlock(NODE_RUNTIME_PIN, NODE_RUNTIME_ASSETS, NODE_RUNTIME_COMPAT_ASSETS) + ) + expect(regenerated).toBe(source) + }) + + it('refuses a file without markers', () => { + expect(() => replaceGeneratedBlock('export {}\n', 'x')).toThrow(/markers/) + }) +}) diff --git a/config/scripts/verify-linux-glibc-floor.cjs b/config/scripts/verify-linux-glibc-floor.cjs index ebfdb2b88161..a41c4d86ca18 100644 --- a/config/scripts/verify-linux-glibc-floor.cjs +++ b/config/scripts/verify-linux-glibc-floor.cjs @@ -23,6 +23,38 @@ const VERSION_FLOORS = Object.freeze([ ]) const FLOOR_LABEL = 'Ubuntu 20.04 (glibc 2.31 / libstdc++ GLIBCXX_3.4.28)' +function glibcFloorProfile(label, glibc, glibcxx, cxxabi) { + return Object.freeze({ + label, + families: Object.freeze([ + Object.freeze({ prefix: 'GLIBC_', floor: Object.freeze(glibc) }), + Object.freeze({ prefix: 'GLIBCXX_', floor: Object.freeze(glibcxx) }), + Object.freeze({ prefix: 'CXXABI_', floor: Object.freeze(cxxabi) }) + ]) + }) +} + +/** The packaged desktop app's floor; the default for every check here. */ +const DESKTOP_GLIBC_FLOOR = Object.freeze({ label: FLOOR_LABEL, families: VERSION_FLOORS }) + +// Why lower than the desktop: orcad's glibc slots ship to SSH hosts the pinned Node 24 already +// runs on (glibc 2.28), and a 2.31 slot left 2.28-2.30 hosts with a runtime but no terminal +// (design D6). RHEL 8 / Debian 10 ship GCC 8's libstdc++. +const SERVER_SLOT_GLIBC_FLOOR = glibcFloorProfile( + 'glibc 2.28 (RHEL 8 / Debian 10, libstdc++ GLIBCXX_3.4.25)', + [2, 28], + [3, 4, 25], + [1, 3, 11] +) + +// The linux-x64-glibc217 compat slot beside the unofficial glibc-217 Node (design D6 rung B). +const COMPAT_SLOT_GLIBC_FLOOR = glibcFloorProfile( + 'glibc 2.17 (CentOS 7, libstdc++ GLIBCXX_3.4.19)', + [2, 17], + [3, 4, 19], + [1, 3, 7] +) + // Why: the sherpa-onnx speech prebuilt is a third-party manylinux binary that // already requires GLIBCXX_3.4.29 (GCC 11 / Ubuntu 21.10+, 22.04 LTS). It loads // lazily in the speech worker (src/main/speech/stt-worker.ts), never at app @@ -102,8 +134,8 @@ function parseVersionNeeds(objdumpOutput) { * Named libstdc++ nodes (`CXXABI_TM_1`, `GLIBCXX_LDBL_*`) ship on 20.04. * Families we do not gate (`GCC_`, `NSS_`) return false. */ -function isVersionNodeAboveFloor(name) { - for (const { prefix, floor } of VERSION_FLOORS) { +function isVersionNodeAboveFloor(name, glibcFloor = DESKTOP_GLIBC_FLOOR) { + for (const { prefix, floor } of glibcFloor.families) { if (!name.startsWith(prefix)) { continue } @@ -126,12 +158,12 @@ function isLibstdcxxNode(name) { * `sherpa-onnx` is exempt from the libstdc++ floor (see LIBSTDCXX_FLOOR_EXEMPT) * but its glibc needs are still checked. */ -function findFloorViolations(needs, filePath = '') { +function findFloorViolations(needs, filePath = '', glibcFloor = DESKTOP_GLIBC_FLOOR) { const exemptLibstdcxx = LIBSTDCXX_FLOOR_EXEMPT.test(filePath) return needs.filter( (need) => !need.weak && - isVersionNodeAboveFloor(need.name) && + isVersionNodeAboveFloor(need.name, glibcFloor) && !(exemptLibstdcxx && isLibstdcxxNode(need.name)) ) } @@ -404,11 +436,13 @@ function readImportedSymbols(filePath, objdumpPath) { * Fail Linux packaging if any bundled native binary under `rootDir` requires a * glibc/libstdc++ symbol version newer than the floor OS. No-op is not allowed * on Linux: a missing objdump throws, because a silent skip would defeat the - * regression gate on exactly the host where it matters. + * regression gate on exactly the host where it matters. `options.glibcFloor` + * selects a floor profile; the default is the desktop's. */ function verifyLinuxGlibcFloor(rootDir, options = {}) { const binaries = collectNativeBinaries(rootDir) const targetArch = options.targetArch + const glibcFloor = options.glibcFloor ?? DESKTOP_GLIBC_FLOOR if (binaries.length === 0) { console.log(`[verify-linux-glibc-floor] OK — no bundled native binaries under ${rootDir}`) return @@ -451,7 +485,7 @@ function verifyLinuxGlibcFloor(rootDir, options = {}) { const { versionNeeds, neededLibraries, isStatic } = readDynamicInfo(filePath, objdumpPath) const isMuslTarget = isMuslTemplatePayload(filePath, neededLibraries, versionNeeds) // Remote musl payloads use their host's C++ runtime, not Ubuntu's libstdc++ or libutil. - const floorViolations = findFloorViolations(versionNeeds, filePath).filter( + const floorViolations = findFloorViolations(versionNeeds, filePath, glibcFloor).filter( (need) => !isMuslTarget || !isLibstdcxxNode(need.name) ) // Only pay for `objdump -T` when a relocated-symbol provider is not already @@ -486,14 +520,14 @@ function verifyLinuxGlibcFloor(rootDir, options = {}) { .join('\n') throw new Error( `[verify-linux-glibc-floor] ${offenders.length} bundled native binar${offenders.length === 1 ? 'y' : 'ies'} ` + - `will not load on ${FLOOR_LABEL}, so the app will crash on startup there:\n${detail}\n` + + `will not load on ${glibcFloor.label}, so the app will crash on startup there:\n${detail}\n` + 'See docs/reference/linux-glibc-compatibility.md — rebuild the offending module against an older ' + 'toolchain or pin the relocated symbols (as config/patches/node-pty@1.1.0.patch does).' ) } console.log( - `[verify-linux-glibc-floor] OK — ${binaries.length} bundled native binaries meet applicable ${FLOOR_LABEL} requirements` + `[verify-linux-glibc-floor] OK — ${binaries.length} bundled native binaries meet applicable ${glibcFloor.label} requirements` ) } @@ -505,6 +539,9 @@ module.exports = { findArchViolation, VERSION_FLOORS, FLOOR_LABEL, + DESKTOP_GLIBC_FLOOR, + SERVER_SLOT_GLIBC_FLOOR, + COMPAT_SLOT_GLIBC_FLOOR, RELOCATED_SYMBOL_PROVIDERS, parseGlibcVersion, compareGlibcVersions, diff --git a/config/scripts/verify-linux-glibc-floor.test.mjs b/config/scripts/verify-linux-glibc-floor.test.mjs index e2b76b7841f6..73ff2c7f67da 100644 --- a/config/scripts/verify-linux-glibc-floor.test.mjs +++ b/config/scripts/verify-linux-glibc-floor.test.mjs @@ -17,6 +17,9 @@ const { parseImportedSymbols, isVersionNodeAboveFloor, findFloorViolations, + DESKTOP_GLIBC_FLOOR, + SERVER_SLOT_GLIBC_FLOOR, + COMPAT_SLOT_GLIBC_FLOOR, findMissingProviderDeps, collectNativeBinaries, verifyLinuxGlibcFloor @@ -119,6 +122,52 @@ describe('verify-linux-glibc-floor parsing', () => { }) }) +describe('floor profiles', () => { + const needs = (...names) => names.map((name) => ({ library: 'libc.so.6', name, weak: false })) + const flagged = (list, floor) => + findFloorViolations(list, '/opt/orcad/pty.node', floor).map((v) => v.name) + + it('keeps the desktop default at Ubuntu 20.04', () => { + expect(DESKTOP_GLIBC_FLOOR.label).toContain('glibc 2.31') + expect(flagged(needs('GLIBC_2.31', 'GLIBC_2.32'))).toEqual(['GLIBC_2.32']) + expect(flagged(needs('GLIBC_2.31', 'GLIBC_2.32'), DESKTOP_GLIBC_FLOOR)).toEqual(['GLIBC_2.32']) + }) + + it('gates server slots at glibc 2.28 and GCC 8 libstdc++', () => { + expect( + flagged( + needs( + 'GLIBC_2.28', + 'GLIBC_2.29', + 'GLIBCXX_3.4.25', + 'GLIBCXX_3.4.26', + 'CXXABI_1.3.11', + 'CXXABI_1.3.12' + ), + SERVER_SLOT_GLIBC_FLOOR + ) + ).toEqual(['GLIBC_2.29', 'GLIBCXX_3.4.26', 'CXXABI_1.3.12']) + }) + + it('gates the compat slot at glibc 2.17 and GCC 4.8 libstdc++', () => { + expect( + flagged( + needs('GLIBC_2.17', 'GLIBC_2.18', 'GLIBCXX_3.4.19', 'GLIBCXX_3.4.20', 'CXXABI_1.3.8'), + COMPAT_SLOT_GLIBC_FLOOR + ) + ).toEqual(['GLIBC_2.18', 'GLIBCXX_3.4.20', 'CXXABI_1.3.8']) + }) + + it('still rejects non-numeric glibc nodes under every profile', () => { + for (const floor of [SERVER_SLOT_GLIBC_FLOOR, COMPAT_SLOT_GLIBC_FLOOR]) { + expect(flagged(needs('GLIBC_ABI_DT_RELR', 'GLIBC_PRIVATE'), floor)).toEqual([ + 'GLIBC_ABI_DT_RELR', + 'GLIBC_PRIVATE' + ]) + } + }) +}) + describe('DT_NEEDED provider check', () => { const OBJDUMP_P_DYNAMIC = [ 'Dynamic Section:', @@ -297,6 +346,24 @@ describe.skipIf(process.platform === 'win32')('verifyLinuxGlibcFloor', () => { } }) + it('applies the floor profile it is given and names it in the failure', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-glibc-profile-')) + try { + const objdumpPath = await writeStubObjdump(root) + await mkdir(join(root, 'app'), { recursive: true }) + await writeFile(join(root, 'app', 'good-pty.node'), ELF_HEADER) // GLIBC_2.28 + const verify = (glibcFloor) => + verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath, glibcFloor }) + + expect(() => verify(SERVER_SLOT_GLIBC_FLOOR)).not.toThrow() + expect(() => verify(COMPAT_SLOT_GLIBC_FLOOR)).toThrow( + /will not load on glibc 2\.17 \(CentOS 7[^\n]*\n\s+good-pty\.node needs GLIBC_2\.28/ + ) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + it('fails closed when objdump cannot read a binary (non-zero exit)', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-glibc-closed-')) try { diff --git a/config/scripts/verify-packaged-orcad-template.cjs b/config/scripts/verify-packaged-orcad-template.cjs index ad0e3fe485ad..e4ecf15f9558 100644 --- a/config/scripts/verify-packaged-orcad-template.cjs +++ b/config/scripts/verify-packaged-orcad-template.cjs @@ -1,16 +1,22 @@ const { createHash } = require('node:crypto') const { lstatSync, readFileSync, readdirSync } = require('node:fs') -const { basename, join } = require('node:path') +const { basename, join, relative, sep } = require('node:path') const { - ORCAD_BUILD_TARGET_FILENAME, + ORCAD_NODE_RUNTIME_MARKER_FILENAME, + ORCAD_SERVER_TARGET_FILENAME, ORCAD_TEMPLATE_MANIFEST_FILENAME, ORCAD_TEMPLATE_TARGETS_DIR, - orcadTemplateCommonFilenames + orcadTemplateCommonFilenames, + orcadTemplateTargetFilenames } = require('../../src/shared/orcad-artifacts.ts') -const { ORCAD_TEMPLATE_TARGETS } = require('../../src/shared/orcad-bun-runtime.ts') +const { + NODE_RUNTIME_ASSETS, + ORCAD_TEMPLATE_TARGETS +} = require('../../src/shared/node-runtime-pin.ts') const SHA256_PATTERN = /^[a-f0-9]{64}$/ const BROWSER_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/ +const TEMPLATE_SCHEMA_VERSION = 3 function sha256(path) { return createHash('sha256').update(readFileSync(path)).digest('hex') @@ -76,10 +82,23 @@ function requireExactNames(actual, expected, label) { } } +function listFiles(root) { + return readdirSync(root, { recursive: true, withFileTypes: true }) + .filter((entry) => !entry.isDirectory()) + .map((entry) => relative(root, join(entry.parentPath, entry.name)).split(sep).join('/')) +} + +function requireContent(path, expected, label) { + if (readFileSync(path, 'utf8').trim() !== expected) { + throw new Error(`[verify-packaged-orcad-template] ${label} disagrees`) + } +} + function verifyTarget(templateDir, target, value) { const targetManifest = requireRecord(value, `${target} manifest`) - const targetSha256 = requireSha256(targetManifest.targetSha256, `${target} targetSha256`) - const watcherSha256 = requireSha256(targetManifest.watcherSha256, `${target} watcherSha256`) + const files = requireRecord(targetManifest.files, `${target} files`) + const expectedFiles = orcadTemplateTargetFilenames(target) + requireExactNames(Object.keys(files), expectedFiles, `${target} manifest inventory`) const hasBrowserName = Object.hasOwn(targetManifest, 'browserName') const hasBrowserSha256 = Object.hasOwn(targetManifest, 'browserSha256') if (hasBrowserName !== hasBrowserSha256) { @@ -88,14 +107,21 @@ function verifyTarget(templateDir, target, value) { ) } const targetDir = join(templateDir, ORCAD_TEMPLATE_TARGETS_DIR, target) - const targetIdentity = join(targetDir, ORCAD_BUILD_TARGET_FILENAME) - verifyFile(targetIdentity, targetSha256, `${target} build target`) - if (readFileSync(targetIdentity, 'utf8').trim() !== target) { - throw new Error(`[verify-packaged-orcad-template] ${target} build target identity disagrees`) + for (const filename of expectedFiles) { + verifyFile( + join(targetDir, ...filename.split('/')), + requireSha256(files[filename], `${target} ${filename} checksum`), + `${target} ${filename}` + ) } - verifyFile(join(targetDir, 'watcher.node'), watcherSha256, `${target} watcher`) + requireContent(join(targetDir, ORCAD_SERVER_TARGET_FILENAME), target, `${target} server target`) + requireContent( + join(targetDir, ORCAD_NODE_RUNTIME_MARKER_FILENAME), + NODE_RUNTIME_ASSETS[target].executableSha256, + `${target} runtime reference` + ) - const expectedFiles = [ORCAD_BUILD_TARGET_FILENAME, 'watcher.node'] + const inventory = [...expectedFiles] if (hasBrowserName) { const browserName = targetManifest.browserName if ( @@ -110,16 +136,18 @@ function verifyTarget(templateDir, target, value) { requireSha256(targetManifest.browserSha256, `${target} browserSha256`), `${target} browser` ) - expectedFiles.push(browserName) + inventory.push(browserName) } - requireExactNames(readdirSync(targetDir), expectedFiles, `${target} file inventory`) + requireExactNames(listFiles(targetDir), inventory, `${target} file inventory`) } function verifyPackagedOrcadTemplate(resourcesDir) { const templateDir = join(resourcesDir, 'orcad-template') const manifest = requireRecord(readManifest(templateDir), 'manifest') - if (manifest.schemaVersion !== 2) { - throw new Error('[verify-packaged-orcad-template] manifest schemaVersion must be 2') + if (manifest.schemaVersion !== TEMPLATE_SCHEMA_VERSION) { + throw new Error( + `[verify-packaged-orcad-template] manifest schemaVersion must be ${TEMPLATE_SCHEMA_VERSION}` + ) } const commonSha256 = requireRecord(manifest.commonSha256, 'commonSha256') const commonFilenames = orcadTemplateCommonFilenames() @@ -143,8 +171,8 @@ function verifyPackagedOrcadTemplate(resourcesDir) { verifyTarget(templateDir, target, targets[target]) } console.log( - `[verify-packaged-orcad-template] OK — verified ${ORCAD_TEMPLATE_TARGETS.length} Bun targets` + `[verify-packaged-orcad-template] OK — verified ${ORCAD_TEMPLATE_TARGETS.length} Node targets` ) } -module.exports = { verifyPackagedOrcadTemplate } +module.exports = { TEMPLATE_SCHEMA_VERSION, verifyPackagedOrcadTemplate } diff --git a/config/scripts/verify-packaged-orcad-template.test.mjs b/config/scripts/verify-packaged-orcad-template.test.mjs index 9be9b4564396..6bd74f08596c 100644 --- a/config/scripts/verify-packaged-orcad-template.test.mjs +++ b/config/scripts/verify-packaged-orcad-template.test.mjs @@ -3,7 +3,9 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' +import { createHash } from 'node:crypto' import { + ORCAD_NODE_RUNTIME_MARKER_FILENAME, ORCAD_TEMPLATE_MANIFEST_FILENAME, ORCAD_TEMPLATE_TARGETS_DIR } from '../../src/shared/orcad-artifacts.ts' @@ -35,20 +37,53 @@ describe('verifyPackagedOrcadTemplate', () => { it('rejects target-native bytes changed after manifest generation', async () => { const fixture = await createFixture() await writeFile( - join(fixture.templateDir, ORCAD_TEMPLATE_TARGETS_DIR, 'linux-x64-glibc', 'watcher.node'), + join( + fixture.templateDir, + ORCAD_TEMPLATE_TARGETS_DIR, + 'linux-x64-glibc', + 'node_modules/node-pty/build/Release/pty.node' + ), 'mutated' ) expect(() => verifyPackagedOrcadTemplate(fixture.root)).toThrow( - 'linux-x64-glibc watcher checksum mismatch' + 'linux-x64-glibc node_modules/node-pty/build/Release/pty.node checksum mismatch' ) }) - it('rejects a missing Windows PTY gate worker', async () => { + it('rejects a target whose runtime reference names another Node', async () => { const fixture = await createFixture() - await rm(join(fixture.templateDir, 'windows-bun-pty-gate-entry.js')) + const markerPath = join( + fixture.templateDir, + ORCAD_TEMPLATE_TARGETS_DIR, + 'darwin-arm64', + ORCAD_NODE_RUNTIME_MARKER_FILENAME + ) + await writeFile(markerPath, `${'0'.repeat(64)}\n`) + const manifestPath = join(fixture.templateDir, ORCAD_TEMPLATE_MANIFEST_FILENAME) + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) + manifest.targets['darwin-arm64'].files[ORCAD_NODE_RUNTIME_MARKER_FILENAME] = createHash( + 'sha256' + ) + .update(`${'0'.repeat(64)}\n`) + .digest('hex') + await writeFile(manifestPath, JSON.stringify(manifest)) + + expect(() => verifyPackagedOrcadTemplate(fixture.root)).toThrow( + 'darwin-arm64 runtime reference disagrees' + ) + }) + + it('rejects a stray Bun-era file in a target directory', async () => { + const fixture = await createFixture() + await writeFile( + join(fixture.templateDir, ORCAD_TEMPLATE_TARGETS_DIR, 'linux-x64-musl', '.build-target'), + 'linux-x64-musl\n' + ) - expect(() => verifyPackagedOrcadTemplate(fixture.root)).toThrow('windows-bun-pty-gate-entry.js') + expect(() => verifyPackagedOrcadTemplate(fixture.root)).toThrow( + 'linux-x64-musl file inventory mismatch' + ) }) it.each(['writer', 'backup'])( @@ -85,6 +120,15 @@ describe('verifyPackagedOrcadTemplate', () => { ) ).toBe(false) } + // The pinned Node a local build references is ~120 MB; none of these outputs is desktop code. + expect(builderConfig.files).toEqual( + expect.arrayContaining([ + '!out/orcad{,/**/*}', + '!out/orcad-*{,/**/*}', + '!out/runtimes{,/**/*}', + '!out/node-runtime-cache{,/**/*}' + ]) + ) const { scripts } = JSON.parse(await readFile(join(process.cwd(), 'package.json'), 'utf8')) for (const name of ['build:desktop', 'build:release', 'build:release:parallel']) { expect(scripts[name]).not.toContain('build:orcad-template') diff --git a/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs b/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs index fd9e1790d560..80263ec4b65c 100644 --- a/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs +++ b/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs @@ -38,7 +38,6 @@ const WINDOWS_SHIM_SPAWN_ALLOWLIST = [ 'config/scripts/build-linux-local.mjs', 'config/scripts/build-linux-local.test.mjs', // Benchmarks, repros and e2e drivers — developer-invoked or Linux-only in CI. - 'config/scripts/build-orcad-prebuilds.mjs', 'config/scripts/run-ai-vault-typing-bench.mjs', 'config/scripts/run-ephemeral-vm-runtime-store-rollback-repro.mjs', 'config/scripts/run-local-ssh-browser-routing-e2e.mjs', diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 2d41bdc44121..903562b67ede 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -12,6 +12,7 @@ "../src/main/codebuddy/hook-service.ts", "../src/main/agent-hooks/hook-stdin-contract.ts", "../src/main/agent-hooks/hook-post-command.ts", + "../src/main/agent-hooks/hook-process-capture.ts", "../src/main/agent-hooks/hook-config-write-path.ts", "../src/main/agent-hooks/hooks-json-read.ts", "../src/main/agent-hooks/installer-utils.ts", @@ -124,6 +125,7 @@ "../src/main/codex/codex-config-settings-upsert.ts", "../src/main/codex/codex-daemon-socket-path-guard.ts", "../src/main/codex/codex-home-paths.ts", + "../src/main/codex/codex-hook-command-form.ts", "../src/main/codex/codex-hook-definition.ts", "../src/main/codex/codex-managed-home-resource-copy-marker.ts", "../src/main/codex/codex-managed-trust-grant-plan.ts", @@ -141,18 +143,25 @@ "../src/main/codex/codex-hook-trust-grant.ts", "../src/main/codex/codex-hook-trust-queue.ts", "../src/main/codex/codex-hook-user-mirroring.ts", + "../src/main/codex/codex-hook-retired-commands.ts", "../src/main/codex/codex-hook-wsl-runtime.ts", "../src/main/codex/codex-managed-trust-reconciliation.ts", "../src/main/codex/codex-process-exit-deadline.ts", + "../src/main/codex/codex-real-home-background-grant.ts", + "../src/main/codex/codex-real-home-entry-trust.ts", + "../src/main/codex/codex-real-home-hook-entry-plan.ts", + "../src/main/codex/codex-real-home-hook-install.ts", + "../src/main/codex/codex-real-home-hook-sweep.ts", + "../src/main/codex/codex-real-home-hook-withdrawal.ts", + "../src/main/codex/codex-real-home-hooks-json.ts", "../src/main/codex/codex-state-db.ts", "../src/main/codex/codex-trust-identity.ts", - "../src/main/codex/codex-trust-config-rollback.ts", "../src/main/codex/codex-trust-config-mutation-queue.ts", "../src/main/codex/codex-trust-grant-telemetry.ts", "../src/main/codex/codex-trust-grant-host.ts", + "../src/main/codex/codex-trust-grant-cooldown.ts", "../src/main/codex/codex-trust-grant-ledger.ts", - "../src/main/codex/codex-user-hook-trust-rebase-client.ts", - "../src/main/codex/codex-user-hook-trust-rebase.ts", + "../src/main/codex/codex-user-hook-trust-moves.ts", "../src/main/codex/codex-wsl-hook-install-plan.ts", "../src/main/codex/config-settings-baseline.ts", "../src/main/codex/config-settings-conflict-resolution.ts", @@ -178,7 +187,6 @@ "../src/main/codex/config-toml-trust.ts", "../src/main/codex/hook-service.ts", "../src/main/codex/hook-trust-promotion.ts", - "../src/main/codex/managed-home-shell-preflight.ts", "../src/main/codex-accounts/fs-utils.ts", "../src/main/codex-accounts/wsl-codex-command.ts", "../src/main/codex-cli/command.ts", @@ -235,12 +243,10 @@ "../src/main/startup/cli-command-names.ts", "../src/main/runtime/runtime-metadata.ts", "../src/main/sqlite/sync-database.ts", - "../src/main/sqlite/bun-readonly-wal.ts", "../src/main/sqlite/sqlite-statement.ts", "../src/main/sqlite/sqlite-integer-reader.ts", + "../src/main/sqlite/node-sqlite-reader-api.ts", "../src/main/sqlite/node-sqlite-statement.ts", - "../src/main/sqlite/bun-sqlite-statement.ts", - "../src/main/sqlite/bun-sqlite-database.ts", "../src/main/win32-utils.ts" ], "compilerOptions": { diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index ff4e9a762764..7d4d951d2d62 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -10,6 +10,7 @@ "../src/renderer/src/lib/skill-freshness-display-status.ts", "../src/renderer/src/components/native-chat/native-chat-resolution-receipt.ts", "../src/renderer/src/components/native-chat/structured-agent-question-projection.ts", + "../src/renderer/src/components/native-chat/native-chat-subagent-delegation.ts", "../src/preload/**/*", "../src/shared/**/*", "../src/relay/**/*", diff --git a/config/vitest.config.ts b/config/vitest.config.ts index 1f385dd7e5ab..81c8133ea39a 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -17,8 +17,6 @@ export default defineConfig({ }, test: { environment: 'node', - // Bun's external-module cache otherwise loses Zod named exports across mocked graphs. - ...(process.versions.bun ? { server: { deps: { inline: ['zod'] } } } : {}), ...(process.env.ORCA_BALANCE_UNIT_SHARDS === '1' ? { sequence: { sequencer: TimingSequencer }, diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index a38a95a2da44..12b4c6c061e0 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 84m + + downloads: 86m @@ -15,7 +15,7 @@ downloads downloads - 84m - 84m + 86m + 86m diff --git a/docs/reference/agent-pty-transcript-capture.md b/docs/reference/agent-pty-transcript-capture.md index 934f0028a93f..4b0f41c46d30 100644 --- a/docs/reference/agent-pty-transcript-capture.md +++ b/docs/reference/agent-pty-transcript-capture.md @@ -103,7 +103,9 @@ text is tested on something no pane ever sees. `src/main/runtime/agent-transcript-pane-test-harness.ts` builds the pane; `src/main/runtime/terminal-interactive-wait-visibility.test.ts` (cursor-agent) and -`src/main/runtime/antigravity-readiness-transcripts.test.ts` (Antigravity) are the two consumers. +`src/main/runtime/antigravity-readiness-transcripts.test.ts` (Antigravity) are examples. An agent +whose readiness is read off the live screen gets its suite from +`src/main/runtime/screen-ruled-agent-transcript-suite.ts`. ## Worked example: the Antigravity captures diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index b7ce4fa16dde..b962df41edc5 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -94,16 +94,16 @@ The structured feed keeps its job of projecting a session's journal into a summary and streaming it to subscribers. On every publish it additionally ingests the summary into the hook server as a status row: -| Row field | From | -| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `paneKey` | `structuredAgentSessionPaneKey(tabId, sessionId)`, the key the renderer already uses; its leaf is UUID-shaped so pane-key validation accepts it | -| `tabId` | `structuredAgentSessionTabId(sessionId)` | -| `worktreeId` | `summary.workspaceId` (a folder workspace id is a valid value) | -| `state` | `structuredAgentSessionAgentStatus(summary).state`: the lead's own status folded with its live `backgroundTasks`, so a settled lead whose subagent still runs reads `working` | -| `workingMode` | `'monitoring'` from the same fold when watch loops are the only live child work; omitted otherwise, which clears it on the row | -| `mainAgent` | the main agent's own state before the fold, its last-turn verdict (`summary.turnOutcome`, present only while idle) and its own clock; see "The main agent fact" below | -| `structuredHost` | `'owned'` while `summary.hostExecutionOwned` is set, otherwise `'held'`; `worktree ps` derives its row's `structuredHostOwned` from it | -| prompt, tool, last message, model, provider session | the summary's fields | +| Row field | From | +| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `paneKey` | `structuredAgentSessionPaneKey(tabId, sessionId)`, the key the renderer already uses; its leaf is UUID-shaped so pane-key validation accepts it | +| `tabId` | `structuredAgentSessionTabId(sessionId)` | +| `worktreeId` | `summary.workspaceId` (a folder workspace id is a valid value) | +| `state` | `structuredAgentSessionAgentStatus(summary).state`: the lead's own status folded with the live child records the store holds for the session (not the summary's task list), so a settled lead whose subagent still runs reads `working` | +| `workingMode` | `'monitoring'` from the same fold when watch loops are the only live child work; omitted otherwise, which clears it on the row | +| `mainAgent` | the main agent's own state before the fold, its last-turn verdict (`summary.turnOutcome`, present only while idle) and its own clock; see "The main agent fact" below | +| `structuredHost` | `'owned'` while `summary.hostExecutionOwned` is set, otherwise `'held'`; `worktree ps` derives its row's `structuredHostOwned` from it | +| prompt, tool, last message, model, provider session | the summary's fields | Sessions with no request (`status === null`) produce no row. A request is a turn record, an assistant message, a user message the provider journaled itself @@ -112,7 +112,10 @@ its start refused; a send that was withdrawn, or left undelivered by a restart or a close, fails nobody and makes nothing listable. `summary.turnOutcome` is the latest request's verdict: its turn's outcome, or `failure` for a send the agent or its start refused (a send that joined a running -turn is answered by that turn). The row also publishes `interrupted` from +turn is answered by that turn). A turn the provider gave no outcome reads as its +host-observed end through `agentTurnVerdict`: `interruption` for an `interrupted` +lifecycle, `unconfirmed` for an `unverifiable` one. It is derived on each read, +never journaled. The row also publishes `interrupted` from `mainAgent.outcome`, exactly as the hook lanes do. When the host revokes live ownership the row is re-set without the flag; when the host closes or evicts the session the row is dropped. Both already exist as feed events (`revokeLive` and the roster @@ -202,7 +205,7 @@ Claude, Codex and Grok hook rows and structured-session rows publish the combine rows and terminal-title-only rows carry none, and readers fall back to `state`: ```ts -mainAgent?: { state: AgentStatusState; outcome?: AgentJournalTurnOutcome; stateStartedAt: number } +mainAgent?: { state: AgentStatusState; outcome?: AgentTurnOutcome; stateStartedAt: number } ``` `state` still answers "what should the user see" and folds live child work in, @@ -215,8 +218,12 @@ works (including a child's permission wait) refuses OSC, which carries no child identity; the children's own lifecycle hooks settle it. `outcome` is the recorded verdict on the main agent's most recent finished turn, present only while `mainAgent.state` is `done`. It is reported by the provider, or is a `cancellation` Orca inferred -from the user's own interrupt keystroke (the journal's turn outcome, by -contrast, is never inferred). A plain end of turn carries none, because absent +from the user's own interrupt keystroke, or a `superseded` the host recorded when +a newer request replaced a structured Claude turn before it ended (it names no +sender, and sets no legacy flag), or, on a structured row whose turn the +provider gave no verdict, is what the host observed of its end: `interruption` +(a proven death nobody asked for) or `unconfirmed` (an end it cannot prove, +never success). The journal's turn outcome, by contrast, stores only recorded verdicts: the provider's, a `cancellation`, or the host's `superseded`; `interruption` and `unconfirmed` are derived from the turn's lifecycle state and never stored. A plain end of turn carries none, because absent means unknown and a provider that omits its interrupt flag must not turn a cancel into a success. In the Claude hook lane the cancellation comes primarily from Orca's own @@ -235,21 +242,31 @@ the verdict through `agentVerdictFields`, which carries `interrupted` and the whole `mainAgent` (state, outcome and its own clock) together, so a copy agrees with the row and can date a failure by `mainAgent.stateStartedAt`. -Display reads the verdict through `agentVerdictDisplayMark`: a failure marks the +Display reads the verdict through `agentVerdictDisplayMark`. A fault marks the agent failed whatever the combined state, because it is news the user must see -even while subagents run; a stop marks it interrupted only on a `done` row, so -a stopped or finished main agent with live child work still reads working. +even while subagents run: a `failure`, and an `interruption`, a turn cut short +by anything other than the user or a newer request. A user's stop (`cancellation`) +marks it interrupted, drawn in the muted tone with the row text "Interrupted by user"; +a turn a newer request replaced (`superseded`) marks it interrupted in the same muted +tone with the row text "Interrupted"; and `unconfirmed` marks it unconfirmed, all only +on a `done` row, so a stopped +or finished main agent with live child work still reads working. The folded +turn header follows the same mark: "Failed after N", "Interrupted after N", or +"Worked for N". Each subagent keeps its own row and state. Container rollups (worktree card, terminal tab, Cmd+J) rank a pending question first, then a failure, then live -work, then a stop, then done. On the worktree card, a failure retained after its -agent's pane went away has no expiry, so it ranks below live work and above a -stop. Lifecycle waiters keep reading the combined `state`. +work, then an unconfirmed end, then a user's stop, then done. On the worktree +card, a failure retained after its agent's pane went away has no expiry, so it +ranks below live work and above an unconfirmed end. Lifecycle waiters keep +reading the combined `state`. Policy splits the verdict two ways. Clean-finish policy (hibernation, pane -ownership, the star-nag value moment) treats a failure like a cancellation -(`agentTurnEndedUncleanly`). Attention (completion time, Smart Sort, sticky -retention, Cmd+J Recent) demotes only a turn the user stopped -(`agentTurnStoppedByUser`); a failure ranks like a completion. +ownership, the star-nag value moment) treats a failure, an interruption and an +unconfirmed end like a cancellation (`agentTurnEndedUncleanly`). Attention +(completion time, Smart Sort, sticky retention, Cmd+J Recent) demotes only a +turn ended on purpose, the user's stop or a newer request that replaced it +(`agentTurnEndedOnPurpose`); a failure, an interruption or +an unconfirmed end ranks like a completion. Admission is one function, `normalizeAgentStatusPayload`, on the relay wire, IPC and disk. A malformed `mainAgent` drops the field and keeps the row. Old hosts diff --git a/docs/reference/antigravity-readiness-evidence.md b/docs/reference/antigravity-readiness-evidence.md index db505d2ff033..22839299eec7 100644 --- a/docs/reference/antigravity-readiness-evidence.md +++ b/docs/reference/antigravity-readiness-evidence.md @@ -10,10 +10,72 @@ Real transcripts now exist. They were recorded from a live `agy` on macOS with `src/main/runtime/__fixtures__/`. `src/main/runtime/antigravity-readiness-transcripts.test.ts` replays them through the runtime. -**Headline: the captured ready screen needs a bare-caret rule, and an active model picker must veto -that stale caret.** The earlier detector refused the genuine ready screen and accepted a live model -picker. The shipped attempt-six rule accepts the bare composer caret, while the active-picker guard -keeps a retained caret from satisfying `tui-idle` until `/model` exits. +**Headline (STA-8741, agy 1.2.14): readiness is now read off the live screen, not the text tail.** +The screen's bottom four rows at an idle composer are rule, caret, rule, `? for shortcuts`. The +caret alone is not enough: agy keeps it painted mid-turn and behind the `/model` picker. The hint +row is what changes. [Attempt seven](#attempt-seven-the-live-screen-sta-8741) has the recordings; +the sections after it are the 1.2.0 history that led there. + +## Attempt seven: the live screen (STA-8741) + +Recorded 2026-09-30 on macOS with `agy` 1.2.14 (binary and banner agree), a signed-in Google +account, `AGY_CLI_HIDE_ACCOUNT_INFO=1`, in an already-trusted workspace. All are 120x40 except +where the name says otherwise. `src/main/runtime/antigravity-screen-readiness-transcripts.test.ts` +replays them. + +| Fixture (`antigravity-1-2-14-*.txt`) | Screen at the end | Screen rule | +| ------------------------------------ | --------------------------------------------------- | ----------- | +| `ready` | settled startup, bare `>` | ready | +| `ready-accept-edits` | `> Accept-edits mode: …` (`--mode accept-edits`) | ready | +| `ready-plan` | `> Plan mode: …` (`--mode plan`) | ready | +| `ready-80x24` | settled startup on an 80x24 PTY | ready | +| `picker-dismissed` | `/model` opened, then Esc | ready | +| `turn-ended` | a short turn has ended | ready | +| `model-picker` | `Switch Model` open; the bare `>` is still above it | not ready | +| `command-palette` | `> /` with the palette, hint `esc to cancel` | not ready | +| `busy-thinking` | `Generating...` spinner, bare `>`, `esc to cancel` | not ready | +| `busy-streaming` | answer streaming, bare `>`, `esc to cancel` | not ready | +| `trust-dialog` | untrusted folder, alternate-screen trust menu | not ready | +| `draft` | unsent text in the composer; the hint row is blank | not ready | + +What they show: + +- **The text tail misses three ready screens.** On `ready-accept-edits`, `ready-plan` and + `turn-ended` the line-folded tail never satisfies `findAntigravityReadyPromptIndex`; the screen + rule does. (`turn-ended` is the 1.2.14 form of the old `busy-turn-ended` known defect.) +- **A caret rule is wrong on the screen.** The grid keeps the bare `>` through a turn and behind + the picker. The text tail happened to lose it mid-turn (section 8); the screen does not. +- **The rule can read ready for a moment mid-turn.** Replayed in 64-byte chunks, the submit repaint + clears the composer a moment before `? for shortcuts` becomes `esc to cancel`. So a pane with an + output clock is held to the same 3s quiescence as Codex (tier 1b in `tui-idle-evidence.ts`); an + idle agy goes silent within a second and a turn keeps repainting its spinner. A restored pane with no + clock settles from the screen alone. +- **A name-only title settled the picker.** With the pane titled `agy`, the old ranking reached + its weak title lane and settled with `/model` open. When a trustworthy screen exists it now + decides, and that lane stays shut. +- **A mismatched grid garbles the chrome.** At 80x24, 100x30 or 60x20 the 120x40 recordings lose + the four-row shape, and resizing the model does not make the TUI repaint. So the live screen + counts only when its grid matches the PTY's reported size and is not a reflow the TUI never + repainted for (a re-attach that learned the real size late; a later PTY resize off that size + repaints it). Otherwise every pre-existing lane (text rules, title, quiet process) decides, as + before this change. +- **A readable screen outranks the text.** When the grid is trustworthy and refuses, the text rules + do not overrule it, in the ready-prompt tier or the quiet one; they decide only when there is no + trustworthy grid. The recorded picker-dismissed text over a model-picker screen proves it. + +The visible-read probe's Antigravity branch is retired. It read the provider screen with a looser +rule (any caret after the banner) whenever the pane was Antigravity. The probe now runs the shared +rule for every screen-ruled agent, only for a pane with no output clock. A clocked pane settles +through the poll. The probe's screen read is the draft-blanking read projection, so it restores the +blanked composer row before the rule reads it (Cline's `❯ Ask anything...` otherwise reads as a +bare `❯`). + +Still not captured: a tool-permission prompt (the operator's `toolPermission` is +`always-proceed`, and changing it means editing their settings), and the sign-in, theme, privacy and +update dialogs, for the reasons in the table below. Windows and Linux are unrecorded. + +The 1.2.0 fixtures below were recorded before the recorder stopped writing ahead of shutdown. Their +last bytes are agy's exit teardown (`ESC[J ESC[?2004l`), so their final screens have no hint row. ## Versions @@ -264,4 +326,4 @@ still end on a bare `>`; that claim is untested. The honest summary is that this is a screen-shaped problem being solved with line-shaped tools. A rule over the derived tail can be made much better than what ships today, but the durable fix is to ask the terminal emulator what the bottom row of the screen actually is, rather than inferring it -from a byte stream that was written with cursor addressing. +from a byte stream that was written with cursor addressing. Attempt seven does that. diff --git a/docs/reference/ci-demand-rollout.md b/docs/reference/ci-demand-rollout.md index 081d276c4bfc..c1be4219d6b2 100644 --- a/docs/reference/ci-demand-rollout.md +++ b/docs/reference/ci-demand-rollout.md @@ -3,26 +3,27 @@ This implements the September 28 runner-demand analysis. The baseline inventory covered September 27 04:00–September 28 04:00 UTC: 4,028 workflow runs, with 463 stratified job samples. Estimated occupancy was 1,081 runner-hours, dominated -by PR unit shards (414 hours) and Bun qualification (262 hours). These are +by PR unit shards (414 hours) and Bun qualification (262 hours, since replaced by the +pinned-Node headless lanes). These are sampled sums of job durations across different runner pools, not billing totals or a guaranteed forecast of savings. ## What runs now -| Work | Ordinary draft update | Ready PR / final checks | Main reference | -| ---------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------- | --------------------------------------- | -| Static analysis and types | Immediately | Immediately | Existing workflows | -| Unit suite | Full, with shadow selection evidence | Full | Existing daily Node 24/26 x86 suite | -| Packages | After successful static analysis and types | Same | Existing release workflows | -| Bun persistence | Linux x64 for ordinary runtime changes; all six platforms for sensitive inputs | All six platforms when Bun inputs changed | Full nightly qualification at 11:30 UTC | -| Bun glibc/musl qualification | Sensitive inputs only, after persistence succeeds | Both architectures after persistence succeeds | Both architectures | -| E2E | Existing targeted routing | Existing targeted routing | One complete run at 17:00 UTC | +| Work | Ordinary draft update | Ready PR / final checks | Main reference | +| -------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------- | --------------------------------------- | +| Static analysis and types | Immediately | Immediately | Existing workflows | +| Unit suite | Full, with shadow selection evidence | Full | Existing daily Node 24/26 x86 suite | +| Packages | After successful static analysis and types | Same | Existing release workflows | +| Headless Node persistence | Linux x64 for ordinary runtime changes; all six platforms for sensitive inputs | All six platforms when server inputs changed | Full nightly qualification at 11:30 UTC | +| Headless Node glibc/musl qualification | Sensitive inputs only, after persistence succeeds | Both architectures after persistence succeeds | Both architectures | +| E2E | Existing targeted routing | Existing targeted routing | One complete run at 17:00 UTC | -Bun-sensitive inputs include root/toolchain files, configuration, native code, +Headless-runtime-sensitive inputs include root/toolchain files, configuration, native code, resources, platform-specific paths, persistence, SQLite, orcad, providers, daemon, SSH, relay, and child-process code. Dependency discovery failure, a missing event or an incomplete diff retains full qualification. An unrelated -change still skips Bun through the existing dependency classifier. No native +change still skips these lanes through the existing dependency classifier. No native artifact is shared across platforms or ABIs. The routine draft reduction is an explicit coverage-placement change; it does not assert identical per-update coverage. Every non-draft synchronize event and the ready-for-review event @@ -36,6 +37,11 @@ slow-test assertions are retained. ## Unit selection rollout +PR planning runs alongside typechecking after their shared dependency setup; an +explicit join publishes its artifact before the unit matrix can start. Static +analysis's Node 24 install also prepares the native cache before matrix fan-out. +The daily compatibility workflow retains its separate planner and cache primer. + `ci-unit-plan.mjs` discovers the same include/exclude set as Vitest and follows static imports, re-exports, literal dynamic imports, CommonJS requires and the renderer aliases. Consumers of indirect filesystem/process inputs remain in the diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index 5a59c75db5e1..30e51b7712ce 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -1,7 +1,56 @@ # CI efficiency and runner capacity The [September 28 demand rollout](ci-demand-rollout.md) documents staged checks, -unit-selection evidence, Bun qualification, review cancellation and daily occupancy reports. +unit-selection evidence, headless runtime qualification, review cancellation and daily occupancy reports. + +## October 1 PR concurrency follow-up + +PR planning now shares checkout and dependency setup with typechecking. Planning +runs in the background, with an explicit failure-propagating join before its +artifact is published. Static analysis remains on a separate runner. Its existing +native dependency install is pinned to Node 24 so it also fills the unit matrix's +native cache, replacing the separate conditional PR primer. Daily Node 24/26 +reference planning and priming remain independent. + +The planner also avoids constructing the import graph when global changes or +missing change evidence already require the full suite. This keeps the same +fallback reason, discovery list and execution coverage. Five PR unit shards, +static/type gates, package checks and the shadow-selection policy are retained. + +A [three-sample hosted comparison](https://github.com/stablyai/orca/actions/runs/36833782900) +measured standalone type jobs at 35 / 26 / 42 seconds and planning jobs at +36 / 25 / 24 seconds. Shared type/planning jobs took 38 / 37 / 27 seconds. +The sum of the separate job medians fell from 60 to 37 seconds. Using that estimate with +the 139-second median static job models about 12% less preflight occupancy. +This is not a measured reduction in total CI time or queue delay. All twelve +plans matched, and three deliberately fresh native-cache producers were reused +by three successful consumers with the real native dependency probe. + +After updating to the current main branch, a +[six-pair alternating comparison](https://github.com/stablyai/orca/actions/runs/36840171700) +reset compiler state before every measurement. Direct compiler/planner command +time was 11.59 / 11.31 / 10.96 seconds sequentially versus +6.82 / 6.52 / 6.33 seconds together with restored state. With state deleted, +it was 67.36 / 67.05 / 61.75 versus 55.52 / 60.06 / 55.68 seconds. +All commands passed and plans matched within each comparison. These command +timings exclude setup and queues; compiler variation contributes to the cold +difference. The retained arrangement showed no cold compiler penalty. + +Combining static analysis too was rejected. An +[alternating same-runner comparison](https://github.com/stablyai/orca/actions/runs/36835091650) +saved runner occupancy, but cold compilation slowed from 61–64 to 83–89 seconds +under contention. The combined gate would finish roughly 40 seconds after the +separate static gate. An +[earlier-compiler trial](https://github.com/stablyai/orca/actions/runs/36837523594) +did not remove that penalty. Small localization scheduling changes also lacked +a repeatable gain. The temporary pilot workflows and benchmark tooling are +available in those runs' commits, rather than retained in normal PR CI. + +Line-count reporting stays separate: it uses a small runner and trusted scripts +with PR write permissions. Sharing that job's credentials with PR-source checks +would provide little resource benefit. Reducing unit shards would trade saved +setup for a longer critical path; enabling selected tests requires the existing +week of representative shadow evidence. ## September 27 follow-up @@ -27,7 +76,6 @@ minutes saved across 14 consumers, before artifact transfer overhead. Runner variation is substantial; this is not a measured workflow wall-time reduction. Test coverage and deadlines stay intact. - [PR #23368](https://github.com/stablyai/orca/pull/23368) overlaps shell installation with dependency setup, starts localization extraction before the orcad smoke, and prepares mobile route snapshots while WebKit and the bundle are being built. @@ -35,14 +83,14 @@ Its 27 checks passed without retries; seven existing conditional checks skipped. Same-runner comparisons in both orders measured: -| Work | Before | After | Evidence | -| --- | --- | --- | --- | -| Static block | 63.5 / 74.7s | 38.9 / 55.6s | [Full comparisons](https://github.com/stablyai/orca/actions/runs/36302208990) | -| Shell job, downloads warmed equally | 76.8 / 70.3s | 64.4 / 64.0s | [Controlled shell runs](https://github.com/stablyai/orca/actions/runs/36302612626) | -| Mobile preparation | 19.8–21.6s | 18.0–18.5s | [Eight measurements](https://github.com/stablyai/orca/actions/runs/36302877583) | -| Web projection and mobile build | 17.2–17.3s | 11.7–12.1s | [Eight measurements](https://github.com/stablyai/orca/actions/runs/36302974324) | -| Mobile verifier fixture suite | 25.47 / 25.35s | 20.04 / 19.92s | [Four full-suite runs](https://github.com/stablyai/orca/actions/runs/36302692823) | -| E2E build outputs | 28.6–30.4s | 25.8–27.8s | [Eight measurements](https://github.com/stablyai/orca/actions/runs/36304001325) | +| Work | Before | After | Evidence | +| ----------------------------------- | -------------- | -------------- | ---------------------------------------------------------------------------------- | +| Static block | 63.5 / 74.7s | 38.9 / 55.6s | [Full comparisons](https://github.com/stablyai/orca/actions/runs/36302208990) | +| Shell job, downloads warmed equally | 76.8 / 70.3s | 64.4 / 64.0s | [Controlled shell runs](https://github.com/stablyai/orca/actions/runs/36302612626) | +| Mobile preparation | 19.8–21.6s | 18.0–18.5s | [Eight measurements](https://github.com/stablyai/orca/actions/runs/36302877583) | +| Web projection and mobile build | 17.2–17.3s | 11.7–12.1s | [Eight measurements](https://github.com/stablyai/orca/actions/runs/36302974324) | +| Mobile verifier fixture suite | 25.47 / 25.35s | 20.04 / 19.92s | [Four full-suite runs](https://github.com/stablyai/orca/actions/runs/36302692823) | +| E2E build outputs | 28.6–30.4s | 25.8–27.8s | [Eight measurements](https://github.com/stablyai/orca/actions/runs/36304001325) | Full mobile-job timings were dominated by first-run apt installation and browser test variation; the controlled preparation measurement is the scheduling evidence. @@ -109,7 +157,7 @@ before the composites start; the workflow contract now preserves that ordering. or still-running check falls back to the full checks. Advisory tests retain their normal readiness routing. The mobile and line-count workflows have no draft-dependent work, so they no longer run again when a draft becomes ready. -- Route the Bun matrix using the actual headless build and selected tests' +- Route the headless-runtime matrix using the actual headless build and selected tests' transitive imports, with conservative inclusion for dynamic workers, native inputs, fixtures, and toolchain changes. A graph failure runs the full matrix; manual dispatch still runs all ten platform jobs. The shared test selectors diff --git a/docs/reference/cline-and-prime-agent-readiness-evidence.md b/docs/reference/cline-and-prime-agent-readiness-evidence.md new file mode 100644 index 000000000000..5780226f0b0d --- /dev/null +++ b/docs/reference/cline-and-prime-agent-readiness-evidence.md @@ -0,0 +1,84 @@ +# Cline and Prime Agent readiness: what the transcripts show + +Both agents paint their composer with cursor addressing on the alternate screen, so the line-folded +text tail cannot see it (#23268, #22153). Their readiness is read off the live screen by +`isClineComposerReadyScreen` and `isPrimeAgentComposerReadyScreen`, through the same tiering as +Antigravity ([`antigravity-readiness-evidence.md`](./antigravity-readiness-evidence.md)): a pane +with an output clock is believed only once quiet, and when a trustworthy screen exists it decides, +so the quiet-process lane cannot settle a dialog it cannot see. Without a readable screen (a +re-attached pane whose grid is untrusted) both keep the quiet-process lane they had before; no +rest-signal entry closes it. Recordings follow +[`agent-pty-transcript-capture.md`](./agent-pty-transcript-capture.md) and are replayed by +`cline-screen-readiness-transcripts.test.ts` and `prime-agent-screen-readiness-transcripts.test.ts`. + +## Cline + +Recorded 2026-09-30 on macOS, `cline` 3.0.66, OpenRouter's free router, with an isolated +`--config`/`--data-dir`. `cline-3-0-65-win32-startup.txt` is a Windows capture from PR #23269. + +| Fixture (`cline-3-0-66-*.txt`) | Screen at the end | Rule | +| ------------------------------ | --------------------------------------------------- | ----------- | +| `ready`, `ready-80x24` | startup composer, `❯ What can I do for you?` | ready | +| `ready-plan` | Plan mode, `❯ Plan something...` | ready | +| `turn-ended` | after a turn, `❯ Ask anything...` | ready | +| `promo` | "Introducing Cline Desktop" drawn over the composer | not ready | +| `permission` | `Approve tool call?` with `[y] Approve [n] Deny` | not ready | +| `slash-menu` | `❯ /` with the command list under it | not ready | +| `draft` | unsent text in the composer | not ready | +| `busy-streaming` | a reply streaming, spinner scrolled off the top | reads ready | + +- **The streaming screen is the idle screen.** Once a long reply scrolls its spinner row away, the + grid is the same composer box as at rest. Only quiescence separates them. A clockless restored + pane still settles from the screen, like Antigravity and Prime: `onPtyData` stamps + `lastOutputAt` on every chunk, so a streaming pane has a clock from its first byte after attach, + and only a pane that has printed nothing since attach is judged on the screen alone. A reply that + stalls for 3s with its spinner off screen would read ready; that is not captured and not ruled + out. +- **The placeholder is not fixed.** It changes with mode and history, so the rule accepts the three + captured placeholders and nothing else. A typed draft looks the same to the read projection, which + is why screen-ruled agents read raw rows (`readScreenRuledLines`, which also requires the PTY's + own grid). Every other agent keeps `readLiveTerminalScreenLines` exactly as before: replaying all + 93 other fixture/grid pairs frame by frame gives identical verdicts on this branch and its base. +- **The promo popup appears about 40ms after the composer** and returns on each launch until it + is dismissed once (`cli-notices.json`). The quiet lane covers that race; the popup carries no + blocked wording. +- **The approval prompt is quiet and unworded.** No blocked rule matches it, so before the screen + decided, the quiet-process lane would have settled it. That lane stays open only while the pane + has no readable screen. +- **Windows:** the reported bug (#23268) is Windows, where the text tail reorders rows. Only the + 3.0.65 contributor capture covers it, and it reads ready from the screen. The rule depends on + rendered rows, not byte order, but no Windows turn or dialog is recorded. + +## Prime Agent + +Recorded 2026-09-30 on macOS, `prime-agent` 0.9.8, OpenRouter +`inclusionai/ling-3.0-flash-sante:free`, with an isolated `HOME` (the first-launch question only +reappears in a fresh one). `prime-agent-0-9-5-*.txt` are 120x35 captures from PR #22154. + +| Fixture (`prime-agent-0-9-8-*.txt`) | Screen at the end | Rule | +| ----------------------------------- | ---------------------------------------------- | --------- | +| `ready`, `ready-80x24` | bare `>` over the `← manage` footer | ready | +| `ready-after-question` | trace question answered "Not now" | ready | +| `turn-ended`, `tool-turn` | a turn (one with a Python tool call) has ended | ready | +| `trace-question` | animated "Share agent traces" question | not ready | +| `slash-menu` | `> /` with the command list | not ready | +| `busy-streaming` | `⠦ Writing · 6s` status row above the composer | not ready | +| `draft` | unsent text in the composer | not ready | + +- **The footer and caret stay up mid-turn.** Only the braille status row Prime keeps directly above + them says a turn is running, so the rule vetoes on it. +- **The rule reads ready for moments it should not.** Replayed in 64-byte chunks, Prime erases that + status row before redrawing it, and on first launch it paints the idle composer a few tens of + milliseconds before the trace question covers it. Both are covered by quiescence: the spinner and the question's + animation repaint continuously, and an idle Prime is silent. +- **No permission prompt exists to capture.** With default settings Prime ran the tool call without + asking. +- **Why the Prime captures are large.** Prime does no cell diffing. Each synchronized frame + (`ESC[?2026h`…`ESC[?2026l`) erases and rewrites every row it touches, so a streaming turn costs + about 4 KB per spinner tick or token (337 frames, 8,593 `ESC[2K` in 1.45 MB). The first-launch + welcome animates a full-screen dotted background with a colour code per glyph, about 10 KB a + frame at roughly ten frames a second. `busy-streaming` and `trace-question` are truncated to the + first frame that shows the screen their tests need (see each `.meta.json`). + `ready-after-question` cannot be: the animation precedes the answer, and truncation only drops + the end. +- 0.9.4's `← agents/resume` layout is not supported; the rule needs 0.9.5 or later. diff --git a/docs/reference/linux-glibc-compatibility.md b/docs/reference/linux-glibc-compatibility.md index 64e72a67b839..f1dcdda47109 100644 --- a/docs/reference/linux-glibc-compatibility.md +++ b/docs/reference/linux-glibc-compatibility.md @@ -105,16 +105,35 @@ finds is published in `status.get`'s `degradations[]` under `terminal_unavailabl **4. Ship the binary, built from patched sources.** [`config/scripts/build-orcad-prebuilds.mjs`](../../config/scripts/build-orcad-prebuilds.mjs) -(`pnpm run build:orcad-prebuilds`, after `build:orcad`) compiles node-pty for the current -host and files it under `out/orcad/prebuilds//`, where a slot is -`linux-{x64,arm64}-{glibc,musl}` or `darwin-{x64,arm64}`. libc is part of the slot name -because node-pty's own loader falls back to `prebuilds/-` and cannot tell -glibc from musl — a glibc binary parked there is loaded on Alpine and dies at `dlopen`. +(`pnpm run build:orcad-prebuilds`, before `build:orcad`, which copies its target's slot into +the package's `node_modules/node-pty/build/Release`) compiles node-pty for the current +host against the pinned Node's hash-verified headers at N-API 8, and files it under +`out/orcad-prebuilds//`, where a slot is `linux-{x64,arm64}-{glibc,musl}`, +`darwin-{x64,arm64}` or `win32-{x64,arm64}`. Its `manifest.json` records each file's +sha256, the N-API level and, for glibc slots, the highest `GLIBC_` version the binary +needs; the loader checks N-API, libc, arch and that glibc version before it installs a +slot. glibc slots are built in a `manylinux_2_28` (AlmaLinux 8) container and pass the +same gate at a **glibc 2.28 / `GLIBCXX_3.4.25`** floor instead of the desktop's 2.31, because +the pinned Node they ship beside already runs on 2.28 and a 2.31 slot would leave 2.28–2.30 +hosts with a runtime but no terminal (design D6). The container's gcc-toolset supplies C++20 +and links newer libstdc++ symbols statically, so the slot needs only RHEL 8's system +libstdc++. musl slots skip the gate, since they never meet glibc's libraries. libc is part +of the slot name because node-pty's own loader falls back to `prebuilds/-` +and cannot tell glibc from musl — a glibc binary parked there is loaded on Alpine and dies at `dlopen`. The script refuses to compile a tree where `config/patches/node-pty@1.1.0.patch` is not applied: without the patch the prebuilt is a #9902 crash shipped as an artifact rather than a first-connect error. CI runs it once per slot inside the matching container (`--slot=` forces the label), merges the trees, and `--require-slots` fails a release with -a hole in the matrix. +a hole in the matrix; `--require-slots ` checks one slot's files against their +hashes and `--smoke` loads it under the pinned Node and spawns a PTY +(`.github/workflows/node-server-tests.yml` runs both on every slot's runner). + +The opt-in `linux-x64-glibc217` compat slot (rung B, not part of the default matrix) is +built in `manylinux2014_x86_64` (glibc 2.17, devtoolset C++20) with `-static-libstdc++`, +gated at a glibc 2.17 floor, refused if `libstdc++.so`/`libgcc_s.so` remains in +`DT_NEEDED`, and smoked under the unofficial glibc-217 Node pinned in +`NODE_RUNTIME_COMPAT_ASSETS`. Nothing installs it yet: the loader and the SSH deploy +still choose only default slots. ## Adding or upgrading a native dependency diff --git a/docs/site/content/docs/cli/overview.mdx b/docs/site/content/docs/cli/overview.mdx index 1e966c6217c0..6d2f07928561 100644 --- a/docs/site/content/docs/cli/overview.mdx +++ b/docs/site/content/docs/cli/overview.mdx @@ -60,7 +60,7 @@ For tracked multi-agent work, use [Orchestration](/docs/cli/orchestration) inste ## File commands -Open files and diffs in the active Orca worktree from a shell: +Open files and diffs as tabs in their Orca worktree from a shell. They open without changing what you are looking at; add `--focus` to be brought to the file: ``` orca file open src/App.tsx diff --git a/docs/site/content/docs/cli/reference.mdx b/docs/site/content/docs/cli/reference.mdx index 5c0afa52b989..d4cb4346d652 100644 --- a/docs/site/content/docs/cli/reference.mdx +++ b/docs/site/content/docs/cli/reference.mdx @@ -158,7 +158,7 @@ orca file diff src/App.tsx --staged --worktree active --json orca file open-changed --mode both --worktree active --json ``` -Paths are relative to the selected worktree. `open-changed` reads git status and opens changed files in edit, diff, or both modes. +Paths are relative to the selected worktree. `open-changed` reads git status and opens changed files in edit, diff, or both modes. Tabs open without changing what you are looking at; pass `--focus` to be brought to them. ## Built-in browser diff --git a/docs/site/content/docs/telemetry.mdx b/docs/site/content/docs/telemetry.mdx index f7f8568cd959..2dc4549401da 100644 --- a/docs/site/content/docs/telemetry.mdx +++ b/docs/site/content/docs/telemetry.mdx @@ -25,6 +25,7 @@ The categories of behavior we observe: - **Agent errors** — a coarse error category and which agent kind was involved. We never see raw error messages or stack traces; per-incident detail stays in a local diagnostic trace file on your machine and only reaches Orca if you explicitly share a diagnostic bundle. - **Settings** — when you toggle one of a small whitelisted set of feature-flag or UX preferences. We record which preference changed and whether it's a boolean or an enum, never the raw value of any free-form setting. - **Privacy controls** — when you opt in or out of telemetry, so we can tell from aggregate data whether our consent UI is working. +- **SSH remote runtime** — once per SSH host per app session, which runtime Orca used to run on that host and why: coarse host facts (operating system, CPU architecture, C library family and glibc minor version, and the host's Node.js major version when Orca runs on it), the category of any refusal, whether a runtime was uploaded, and a coarse duration bucket. Never the hostname, user name, paths, or raw error text. Fields include fixed enum values, version strings, numeric counts and revisions, and random local IDs. Conversation IDs allow repeated token summaries for the same conversation to be associated; they are pseudonymous. No free-form strings from any UI input ever leave your machine. diff --git a/mobile/app/h/_layout.tsx b/mobile/app/h/_layout.tsx index f1758bd5f0e9..efda4de7d027 100644 --- a/mobile/app/h/_layout.tsx +++ b/mobile/app/h/_layout.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { View, StyleSheet, PanResponder } from 'react-native' -import { Stack, useGlobalSearchParams, usePathname } from 'expo-router' +import { useGlobalSearchParams, usePathname } from 'expo-router' import { colors } from '../../src/theme/mobile-theme' import { useResponsiveLayout } from '../../src/layout/responsive-layout' import { @@ -12,6 +12,7 @@ import { } from '../../src/storage/preferences' import { HostProtocolGate } from '../../src/components/HostProtocolGate' import { HostScreen } from '../../src/host-screen/HostScreen' +import { HostStack } from '../../src/navigation/host-stack' // Keep at least this much room for the detail pane when resizing the sidebar. const MIN_DETAIL_WIDTH = 320 @@ -27,43 +28,6 @@ function clampSidebarToWindow(width: number, windowWidth: number): number { return Math.min(hardMax, Math.max(HOST_SIDEBAR_MIN_WIDTH, Math.round(width))) } -function HostStack({ animation }: { animation: 'none' | 'default' }) { - return ( - - - - - - - - - - - {/* Dev-flag only: redirects to the host screen unless the hybrid shell flag is on. */} - - {/* Last, and matched last: every pathname above has a file of its own, so this takes only - what expo-router would otherwise send to Unmatched. Declared for the title alone — an - undeclared child still renders, appended after these with this group's screenOptions. */} - - - ) -} - export default function HostGroupLayout() { // Wide layout = tablet/foldable canvas (see responsive-layout-metrics). const { isWideLayout, width: windowWidth } = useResponsiveLayout() diff --git a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift index abf9e7a70ede..240264bb977a 100644 --- a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift +++ b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift @@ -201,11 +201,8 @@ final class OrcaMobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate // claim here rests on them being absent. configuration.websiteDataStore = .nonPersistent() configuration.preferences.javaScriptCanOpenWindowsAutomatically = false - // WebKit's text interaction assistant wins the hold and raises its selection loupe, so the page - // never sees a long press and every long-press action in it is dead (lane C1.7, measured). - // Unguarded: the API is iOS 14.5+ and this target's floor is 15.1, so `#available` would be - // dead code the compiler warns on. - configuration.preferences.isTextInteractionEnabled = false + // Text interaction stays on: with it off a focused field gets keydown but never text. The page's + // `user-select: none` is what keeps WebKit's selection off its long presses. configuration.setURLSchemeHandler(schemeHandler, forURLScheme: MobileWebShellOrigin.scheme) configuration.userContentController.addUserScript(Self.makeBlockerScript()) bridgeReceiver.view = self diff --git a/mobile/src/browser/use-mobile-browser-interactions.ts b/mobile/src/browser/use-mobile-browser-interactions.ts index 0f09dc595c70..00815377f0d8 100644 --- a/mobile/src/browser/use-mobile-browser-interactions.ts +++ b/mobile/src/browser/use-mobile-browser-interactions.ts @@ -281,7 +281,9 @@ export function useMobileBrowserInteractions(args: MobileBrowserInteractionArgs) scrollingRef.current = false startPointRef.current = null }, - onPanResponderTerminationRequest: () => true + // The long-press right-click outlives the WebView's own long-press contextmenu at ~500 ms. + onPanResponderTerminationRequest: (event) => + !('type' in event.nativeEvent) || event.nativeEvent.type !== 'contextmenu' }), [clearLongPressTimer, handleResponderGrant, handleResponderMove, handleResponderRelease] ) diff --git a/mobile/src/components/AgentStateDot.tsx b/mobile/src/components/AgentStateDot.tsx index 80ef9d9d239e..6912f6c1b2af 100644 --- a/mobile/src/components/AgentStateDot.tsx +++ b/mobile/src/components/AgentStateDot.tsx @@ -2,18 +2,21 @@ import { useEffect, useRef } from 'react' import { Activity } from 'lucide-react-native' import { Animated, Easing, StyleSheet, View } from 'react-native' import type { AgentDotState } from '../worktree/agent-row-display' +import { colors } from '../theme/mobile-theme' // Per-agent state indicator, 1:1 with desktop AgentStateDot // (src/renderer/src/components/AgentStateDot.tsx): yellow spinner for 'working', -// emerald for 'done', red for blocked/waiting/interrupted/failed (attention), neutral -// for idle. Distinct from the worktree-level AgentSpinner, which collapses the -// agent vocabulary into the 5-state rollup the sidebar dot uses. +// emerald for 'done', red for blocked/waiting/failed (attention), muted for a user's Stop +// ('interrupted'), amber for 'unconfirmed' (desktop's missing-evidence tone), neutral for idle. Distinct from the +// worktree-level AgentSpinner, which collapses the agent vocabulary into the 5-state +// rollup the sidebar dot uses. const DOT_COLORS: Record, string> = { done: '#10b981', blocked: '#ef4444', waiting: '#ef4444', - interrupted: '#ef4444', + interrupted: colors.textMuted, failed: '#ef4444', + unconfirmed: colors.statusAmber, idle: 'rgba(115,115,115,0.4)' } const WORKING_COLOR = '#eab308' diff --git a/mobile/src/components/agent-monitoring-indicators.test.ts b/mobile/src/components/agent-monitoring-indicators.test.ts index eefa2137f143..16c453b27cf0 100644 --- a/mobile/src/components/agent-monitoring-indicators.test.ts +++ b/mobile/src/components/agent-monitoring-indicators.test.ts @@ -3,12 +3,14 @@ import { act, create } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AgentSpinner } from './AgentSpinner' import { AgentStateDot } from './AgentStateDot' +import { colors } from '../theme/mobile-theme' const DESKTOP_WORKING_COLOR = '#eab308' type MonitoringTestRenderer = { readonly root: { findByType(type: string): { props: Record } + findAllByType(type: string): { props: Record }[] } unmount(): void } @@ -84,4 +86,17 @@ describe('mobile monitoring indicators', () => { expect(animationTiming).toHaveBeenCalledOnce() expect(animationLoop).toHaveBeenCalledOnce() }) + + it.each([ + // A user's Stop is not news: muted, never the fault red a failure draws. + ['interrupted', colors.textMuted], + ['failed', '#ef4444'] + ] as const)('draws %s with its own dot colour', async (state, color) => { + await act(async () => { + renderer = create(createElement(AgentStateDot, { state })) + }) + + const dot = renderer?.root.findAllByType('View').find((view) => Array.isArray(view.props.style)) + expect(dot?.props.style).toContainEqual({ backgroundColor: color }) + }) }) diff --git a/mobile/src/components/markdown-href-routing.test.ts b/mobile/src/components/markdown-href-routing.test.ts index 7117a2ecbc86..99ffce93386b 100644 --- a/mobile/src/components/markdown-href-routing.test.ts +++ b/mobile/src/components/markdown-href-routing.test.ts @@ -17,14 +17,6 @@ describe('routeMarkdownHref', () => { }) }) - it('routes relative hrefs to the file opener', () => { - expect(routeMarkdownHref('src/foo.ts')).toEqual({ kind: 'file', pathText: 'src/foo.ts' }) - expect(routeMarkdownHref('./docs/plan.md')).toEqual({ - kind: 'file', - pathText: './docs/plan.md' - }) - }) - it('carries a #L fragment as a :line suffix', () => { expect(routeMarkdownHref('docs/plan.md#L42')).toEqual({ kind: 'file', @@ -40,13 +32,6 @@ describe('routeMarkdownHref', () => { }) }) - it('decodes percent-encoded href paths', () => { - expect(routeMarkdownHref('docs/release%20notes.md')).toEqual({ - kind: 'file', - pathText: 'docs/release notes.md' - }) - }) - it('routes file: URIs to the file opener', () => { expect(routeMarkdownHref('file:///Users/me/wt/src/app.tsx')).toEqual({ kind: 'file', @@ -62,13 +47,6 @@ describe('routeMarkdownHref', () => { }) }) - it('keeps Windows drive paths out of the scheme filter', () => { - expect(routeMarkdownHref(String.raw`C:\repo\src\index.ts`)).toEqual({ - kind: 'file', - pathText: String.raw`C:\repo\src\index.ts` - }) - }) - it('drops anchors, unknown schemes, and empty hrefs', () => { expect(routeMarkdownHref('#section')).toEqual({ kind: 'none' }) expect(routeMarkdownHref('')).toEqual({ kind: 'none' }) diff --git a/mobile/src/components/pr-sidebar/pr-conflict-presentation.test.ts b/mobile/src/components/pr-sidebar/pr-conflict-presentation.test.ts index 8f68ead0d6f2..73ff9253ac54 100644 --- a/mobile/src/components/pr-sidebar/pr-conflict-presentation.test.ts +++ b/mobile/src/components/pr-sidebar/pr-conflict-presentation.test.ts @@ -104,23 +104,4 @@ describe('resolveConflictDisplay', () => { expect(display?.mergeabilityRefreshCommands).toContain('git fetch origin') expect(display?.mergeabilityRefreshCommands).toContain('git commit --allow-empty --only') }) - - it('does not interpolate shell-sensitive base refs into copyable commands', () => { - const display = resolveConflictDisplay( - pr({ - mergeable: 'CONFLICTING', - conflictSummary: { - baseRef: 'release/$USER;echo unsafe', - baseCommit: 'x', - commitsBehind: 1, - files: [], - localMergeState: 'clean' - } - }) - ) - - expect(display?.mergeabilityRefreshCommands).toContain('git fetch origin') - expect(display?.mergeabilityRefreshCommands).not.toContain('$USER') - expect(display?.mergeabilityRefreshCommands).not.toContain('echo unsafe') - }) }) diff --git a/mobile/src/diagnostics/connection-diagnostics-analysis.test.ts b/mobile/src/diagnostics/connection-diagnostics-analysis.test.ts index 762d82b10d50..ab40b3511b27 100644 --- a/mobile/src/diagnostics/connection-diagnostics-analysis.test.ts +++ b/mobile/src/diagnostics/connection-diagnostics-analysis.test.ts @@ -98,23 +98,6 @@ describe('diagnoseConnection', () => { }) }) - it('marks authenticated Relay liveness failures as safe to send', () => { - expect( - diagnoseConnection({ - endpoint: 'ws://192.168.1.2:6768', - state: 'reconnecting', - activePath: 'relay', - entries: [ - { - ...event('Relay health check failed'), - code: 'liveness-timeout', - path: 'relay' - } - ] - }).reportability - ).toBe('orca-relay') - }) - it.each([ ['direct timeout', 'connect-timeout', 'tailscale'], ['handshake timeout', 'handshake-timeout', 'relay'], diff --git a/mobile/src/hooks/mobile-dictation-desktop-start.test.ts b/mobile/src/hooks/mobile-dictation-desktop-start.test.ts index 6fdd66a2354b..8166028d2ddc 100644 --- a/mobile/src/hooks/mobile-dictation-desktop-start.test.ts +++ b/mobile/src/hooks/mobile-dictation-desktop-start.test.ts @@ -150,26 +150,6 @@ describe('startMobileDictationDesktopSession', () => { }) }) - it('does not surface a desktop-start failure after the start became stale', async () => { - let setNewerStart = () => undefined - const harness = createStartHarness({ - sendRequest: async (method) => { - if (method === 'speech.dictation.start') { - setNewerStart() - throw new Error('Desktop start failed') - } - return OK_RESPONSE - } - }) - setNewerStart = harness.setNewerStart - - await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(false) - - expect(harness.setIdle).not.toHaveBeenCalled() - expect(harness.getActiveId()).toBe('dictation-b') - expect(harness.commitRecordingStart).not.toHaveBeenCalled() - }) - it('commits recording before returning a current start to the hook', async () => { const harness = createStartHarness() diff --git a/mobile/src/mobile-web-shell/bridge/page-first-paint.test.tsx b/mobile/src/mobile-web-shell/bridge/page-first-paint.test.tsx index a397729a9e8c..0699f330a2f7 100644 --- a/mobile/src/mobile-web-shell/bridge/page-first-paint.test.tsx +++ b/mobile/src/mobile-web-shell/bridge/page-first-paint.test.tsx @@ -188,29 +188,4 @@ describe('which commit the page reports its frame from', () => { }) expect(reports).toBe(1) }) - - it('reports the screen that arrived and not the one that replaced it', async () => { - const route = deferredRouteChunk() - const Screen = lazy(() => route.chunk.then(withRouteScreenPaintReport)) - let reports = 0 - await act(async () => { - create( - { - reports += 1 - return () => undefined - }} - > - - - - - ) - }) - await act(async () => { - route.arrive() - }) - // Once per screen that commits: the shell latches the first, and a re-render is not a new one. - expect(reports).toBe(1) - }) }) diff --git a/mobile/src/mobile-web-shell/bridge/use-native-verbs.test.tsx b/mobile/src/mobile-web-shell/bridge/use-native-verbs.test.tsx index 9ce2cb04a91d..77fcd9dd0775 100644 --- a/mobile/src/mobile-web-shell/bridge/use-native-verbs.test.tsx +++ b/mobile/src/mobile-web-shell/bridge/use-native-verbs.test.tsx @@ -197,11 +197,6 @@ describe('a verb the shell refuses', () => { expect(reasons).toEqual(['native_verb_failed', 'reply-too-large', 'native_verb_result']) }) - it('names the frame refusal when the reply could never have reached the page', async () => { - const error = await rejectionFrom(() => Promise.resolve({ value: 'a'.repeat(9 * 1024 * 1024) })) - expect(error.reason).toBe('reply-too-large') - }) - it('names the grant when this side refused before sending', async () => { const page = createPageClient() page.deliver({ ...INIT, grants: { ...GRANTS, native: ['navigate'] } }) diff --git a/mobile/src/mobile-web-shell/catch-all-page-route.test.tsx b/mobile/src/mobile-web-shell/catch-all-page-route.test.tsx index 969157ff7810..e4ab0593e5ed 100644 --- a/mobile/src/mobile-web-shell/catch-all-page-route.test.tsx +++ b/mobile/src/mobile-web-shell/catch-all-page-route.test.tsx @@ -176,14 +176,6 @@ describe('the catch-all switch', () => { expect(dependencies.routes[0]?.params).toEqual({ tab: 'diff' }) }) - it('never carries its own segments back as query params', async () => { - // `useLocalSearchParams` merges the route params into the search params, and both of these are - // already in the pathname above. - dependencies.params = { hostId: 'host-1', page: ['settings'] } - await render() - expect(dependencies.routes[0]).not.toHaveProperty('params') - }) - it('refuses a deep link carrying more params than the bridge will take', async () => { // The schema bounds them, so `shellScreenRoute` answers null and the switch refuses. Dropping // the overflow instead would open the page on a screen missing the state it was asked for. diff --git a/mobile/src/navigation/host-stack-screens.ts b/mobile/src/navigation/host-stack-screens.ts new file mode 100644 index 000000000000..ee81ec315aa6 --- /dev/null +++ b/mobile/src/navigation/host-stack-screens.ts @@ -0,0 +1,21 @@ +/** The screens `app/h/_layout.tsx` declares, shared so the native and page stacks cannot drift. */ +export const HOST_STACK_SCREENS = [ + { name: '[hostId]/index', title: 'Host' }, + { name: '[hostId]/edit', title: 'Edit host' }, + { name: '[hostId]/accounts', title: 'Accounts' }, + { name: '[hostId]/tasks', title: 'Tasks' }, + { name: '[hostId]/session/[worktreeId]', title: 'Terminal' }, + { name: '[hostId]/source-control/[worktreeId]', title: 'Source Control' }, + { name: '[hostId]/agent-history/[worktreeId]', title: 'Agent Session History' }, + { name: '[hostId]/review/[worktreeId]', title: 'Changes' }, + { name: '[hostId]/pr/[worktreeId]', title: 'Pull Request' }, + // Dev-flag only: redirects to the host screen unless the hybrid shell flag is on. + { name: '[hostId]/web', title: 'Workspace' }, + // Last, and matched last: every pathname above has a file of its own, so this takes only what + // expo-router would otherwise send to Unmatched. Declared for the title alone — an undeclared + // child still renders, appended after these with this group's screenOptions. + { name: '[hostId]/[...page]', title: 'Workspace' } +] as const + +/** Tablet split view swaps the detail pane instantly; phones slide. */ +export type HostStackAnimation = 'none' | 'default' diff --git a/mobile/src/navigation/host-stack.tsx b/mobile/src/navigation/host-stack.tsx new file mode 100644 index 000000000000..dc03aa8cf529 --- /dev/null +++ b/mobile/src/navigation/host-stack.tsx @@ -0,0 +1,22 @@ +import { Stack } from 'expo-router' +import { colors } from '../theme/mobile-theme' +import { HOST_STACK_SCREENS, type HostStackAnimation } from './host-stack-screens' + +export function HostStack({ animation }: { animation: HostStackAnimation }) { + return ( + + {HOST_STACK_SCREENS.map(({ name, title }) => ( + + ))} + + ) +} diff --git a/mobile/src/navigation/host-stack.web.tsx b/mobile/src/navigation/host-stack.web.tsx new file mode 100644 index 000000000000..a00386e0b9b4 --- /dev/null +++ b/mobile/src/navigation/host-stack.web.tsx @@ -0,0 +1,191 @@ +import { useCallback, useLayoutEffect, useRef, useState, type ReactNode } from 'react' +import { StyleSheet, View } from 'react-native' +import { Navigator } from 'expo-router' +import { colors } from '../theme/mobile-theme' +import { HOST_STACK_SCREENS, type HostStackAnimation } from './host-stack-screens' + +/** + * Web sibling: the page's host stack, with the push and pop slide the native stack has. + * + * expo-router's `Stack` renders native-stack's web view on the page, which flips each screen's + * `display` and ignores `animation`. This is the same expo-router `StackRouter` under the public + * `Navigator`, with a view that keeps a popped screen mounted until it has slid out. + */ +export function HostStack({ animation }: { animation: HostStackAnimation }) { + return ( + + {HOST_STACK_SCREENS.map(({ name, title }) => ( + + ))} + + + ) +} + +const SLIDE_MS = 300 +// Bounds the wait for a suspended screen, so a route that renders nothing still arrives. +const CONTENT_WAIT_CAP_MS = 1000 +// Close to iOS's push curve, so the page and the native stack read as one motion. +const SLIDE_EASING = 'cubic-bezier(0.2, 0.8, 0.2, 1)' + +type NavigatorContext = ReturnType +type Descriptors = NavigatorContext['descriptors'] +type Routes = NavigatorContext['state']['routes'] + +/** A pop needs no under key: the screen beneath it is always the new top. */ +type Transition = + | Readonly<{ kind: 'push'; enteringKey: string; underKey: string }> + | Readonly<{ kind: 'pop'; leaving: Routes[number]; descriptors: Descriptors }> + +type Shown = Readonly<{ + /** The navigator's own routes array, whose identity changes exactly when the state does. */ + routes: Routes + descriptors: Descriptors + transition: Transition | null +}> + +function prefersReducedMotion(): boolean { + return window.matchMedia('(prefers-reduced-motion: reduce)').matches +} + +/** Push = the old top is still in the stack below the new one; pop = the old top left it. */ +function transitionBetween(previous: Shown, next: Routes): Transition | null { + const previousTop = previous.routes.at(-1) + const nextTop = next.at(-1) + if (!previousTop || !nextTop || previousTop.key === nextTop.key) { + return null + } + const nextKeys = next.map((route) => route.key) + if (nextKeys.at(-2) === previousTop.key) { + return { kind: 'push', enteringKey: nextTop.key, underKey: previousTop.key } + } + if (!nextKeys.includes(previousTop.key) && previous.routes.some((r) => r.key === nextTop.key)) { + return { kind: 'pop', leaving: previousTop, descriptors: previous.descriptors } + } + // A replace or a reset has no direction to slide in. + return null +} + +function HostStackView({ animation }: { animation: HostStackAnimation }) { + const { state, descriptors } = Navigator.useContext() + const { routes } = state + const [shown, setShown] = useState({ routes, descriptors, transition: null }) + + // Derived during render, so the first painted frame of a pop still holds the leaving screen. + if (shown.routes !== routes) { + const moved = shown.routes.at(-1)?.key !== routes.at(-1)?.key + const slides = moved && animation !== 'none' && !prefersReducedMotion() + setShown({ + routes, + descriptors, + transition: !moved ? shown.transition : slides ? transitionBetween(shown, routes) : null + }) + } + + const settle = useCallback(() => setShown((current) => ({ ...current, transition: null })), []) + const { transition } = shown + const topKey = routes.at(-1)?.key + // One flat keyed list, so the leaving screen keeps its mount when it moves to the last slot. + const slots = transition?.kind === 'pop' ? [...routes, transition.leaving] : routes + + return ( + // No NavigationContent: it must render in the navigator's own pass, and a settle re-renders + // only this view. expo-router's NavigatorSlot renders descriptors bare for the same reason. + // Untappable mid-slide, as natively: a second tap on the list would push a second screen. + + {slots.map((route) => { + const leaving = transition?.kind === 'pop' && route.key === transition.leaving.key + const entering = transition?.kind === 'push' && route.key === transition.enteringKey + const under = transition?.kind === 'push' && route.key === transition.underKey + const descriptor = leaving ? transition.descriptors[route.key] : descriptors[route.key] + return ( + + {descriptor?.render()} + + ) + })} + + ) +} + +function StackScreen({ + visible, + motion, + onSettled, + children +}: { + visible: boolean + motion: 'enter' | 'exit' | null + onSettled: () => void + children: ReactNode +}) { + const ref = useRef(null) + // Where an interrupted slide stopped, so a pop during a push leaves from there and not from 0. + const stoppedAt = useRef(null) + + // Before paint, so neither screen is ever painted at rest before its slide starts. + useLayoutEffect(() => { + // Consumed by every run, so a slot that settles at rest carries no stale offset. + const from = stoppedAt.current + stoppedAt.current = null + const node: unknown = ref.current + if (motion === null || !(node instanceof HTMLElement)) { + return + } + const frames = + motion === 'enter' + ? [{ transform: 'translateX(100%)' }, { transform: 'translateX(0)' }] + : [{ transform: from ?? 'translateX(0)' }, { transform: 'translateX(100%)' }] + // Web Animations run on the compositor, so a heavy screen mounting does not stall the slide. + const slide = node.animate(frames, { duration: SLIDE_MS, easing: SLIDE_EASING, fill: 'both' }) + slide.onfinish = onSettled + const stop = () => { + stoppedAt.current = getComputedStyle(node).transform + slide.cancel() + } + if (motion === 'exit' || node.childElementCount > 0) { + return stop + } + // A route whose chunk is still loading suspends to an empty screen: hold it off-screen until + // its content commits, so the slide carries the screen and not a blank panel. + slide.pause() + const start = () => { + observer.disconnect() + clearTimeout(cap) + slide.play() + } + const observer = new MutationObserver(start) + observer.observe(node, { childList: true, subtree: true }) + const cap = setTimeout(start, CONTENT_WAIT_CAP_MS) + return () => { + observer.disconnect() + clearTimeout(cap) + stop() + } + }, [motion, onSettled]) + + return ( + + {children} + + ) +} + +const styles = StyleSheet.create({ + stack: { + flex: 1, + overflow: 'hidden' + }, + screen: { + ...StyleSheet.absoluteFillObject, + backgroundColor: colors.bgBase + }, + hidden: { + display: 'none' + } +}) diff --git a/mobile/src/session/MobileNativeChatComposer.tsx b/mobile/src/session/MobileNativeChatComposer.tsx index c5d3d7759cf4..b04e09d80e25 100644 --- a/mobile/src/session/MobileNativeChatComposer.tsx +++ b/mobile/src/session/MobileNativeChatComposer.tsx @@ -31,6 +31,7 @@ import { } from './MobileNativeChatSessionOptionPickers' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' import { mobileNativeChatInputStyles } from './mobile-native-chat-input-styles' +import { keepHeldPressThroughLongPress } from './held-press-long-press' const NO_FILE_PATHS: string[] = [] const NO_ATTACHMENTS: PendingNativeChatImage[] = [] @@ -301,17 +302,26 @@ export function MobileNativeChatComposer({ onPress={dictationMode === 'hold' ? undefined : onMicPress} onPressIn={dictationMode === 'hold' ? onMicPressIn : undefined} onPressOut={dictationMode === 'hold' ? onMicPressOut : undefined} + onLongPress={dictationMode === 'hold' ? keepHeldPressThroughLongPress : undefined} disabled={disabled} > + {/* The icon swaps on press; as the page's touch target, its removal would send + touchend to a detached node and lose the release. */} {micActive ? ( ) : ( - + )} ) : null} diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index bb9d962107af..9c894e3cd7fc 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -123,6 +123,7 @@ function MobileNativeChatMessageImpl({ onToggleTurn(turnKey) : undefined} /> diff --git a/mobile/src/session/MobileNativeChatTurnStatus.test.ts b/mobile/src/session/MobileNativeChatTurnStatus.test.ts index e99412b42bf1..d2069a134b24 100644 --- a/mobile/src/session/MobileNativeChatTurnStatus.test.ts +++ b/mobile/src/session/MobileNativeChatTurnStatus.test.ts @@ -47,6 +47,7 @@ describe('MobileNativeChatTurnStatus', () => { function render(props: { startedAt: number | null workedSeconds?: number | null + verdict?: 'interruption' | 'cancellation' expanded?: boolean onToggleExpanded?: () => void }): ReactTestRenderer { @@ -77,6 +78,24 @@ describe('MobileNativeChatTurnStatus', () => { expect(onToggleExpanded).toHaveBeenCalledOnce() }) + it('heads a turn a crash cut off as failed, and a turn the user stopped as interrupted', () => { + const crashed = render({ + startedAt: Date.now(), + workedSeconds: 12, + verdict: 'interruption', + onToggleExpanded: vi.fn() + }) + expect(labels(crashed.root)).toEqual(['Failed after 12s']) + act(() => crashed.unmount()) + const stopped = render({ + startedAt: Date.now(), + workedSeconds: 12, + verdict: 'cancellation', + onToggleExpanded: vi.fn() + }) + expect(labels(stopped.root)).toEqual(['Interrupted after 12s']) + }) + it('stays a plain row when the settled turn has nothing to disclose', () => { const tree = render({ startedAt: Date.now(), workedSeconds: 5 }) expect(tree.root.findAllByType('Pressable' as never)).toHaveLength(0) diff --git a/mobile/src/session/MobileNativeChatTurnStatus.tsx b/mobile/src/session/MobileNativeChatTurnStatus.tsx index 250036852409..a39e404c6397 100644 --- a/mobile/src/session/MobileNativeChatTurnStatus.tsx +++ b/mobile/src/session/MobileNativeChatTurnStatus.tsx @@ -7,6 +7,7 @@ import { NATIVE_CHAT_TURN_STATUS_COPY, nativeChatElapsedSeconds } from '../../../src/shared/native-chat-turn-status' +import type { AgentTurnOutcome } from '../../../src/shared/agent-turn-outcome' import { colors, spacing, typography } from '../theme/mobile-theme' /** Seconds tick only while a turn is actually counting, so a settled transcript @@ -27,22 +28,26 @@ function useElapsedSeconds(startedAt: number | null, counting: boolean): number } /** The turn bar under the user's message: "Working for 12s" while the turn runs, - * settling in place to a tappable "Worked for 3m 4s" that discloses the turn's - * tool activity. Desktop parity: `NativeChatWorkingStatus`. */ + * settling in place to a tappable "Worked for 3m 4s" ("Interrupted after" for a Stop, + * "Failed after" for a fault) that discloses the turn's tool activity. Desktop parity: + * `NativeChatWorkingStatus`. */ export function MobileNativeChatTurnStatus({ startedAt, workedSeconds, + verdict, expanded = false, onToggleExpanded }: { startedAt: number | null workedSeconds?: number | null + /** How a settled turn ended; it picks the settled label. */ + verdict?: AgentTurnOutcome expanded?: boolean onToggleExpanded?: () => void }): React.JSX.Element { const settled = workedSeconds != null const elapsedSeconds = useElapsedSeconds(startedAt, !settled) - const label = formatNativeChatTurnStatusLabel({ workedSeconds, elapsedSeconds }) + const label = formatNativeChatTurnStatusLabel({ workedSeconds, elapsedSeconds, verdict }) if (settled && onToggleExpanded) { return ( diff --git a/mobile/src/session/MobileSessionCommandDock.tsx b/mobile/src/session/MobileSessionCommandDock.tsx index 87b46246572f..996f610937a6 100644 --- a/mobile/src/session/MobileSessionCommandDock.tsx +++ b/mobile/src/session/MobileSessionCommandDock.tsx @@ -17,6 +17,7 @@ import { } from '../terminal/terminal-keyboard-type' import { MobileTerminalLiveInputStatus } from './MobileTerminalLiveInputStatus' import { MobileTerminalInputActions } from './MobileTerminalInputActions' +import { keepHeldPressThroughLongPress } from './held-press-long-press' import { isTerminalPhoneDisplayMode } from './mobile-session-route-helpers' import { colors } from '../theme/mobile-theme' import { styles } from './mobile-session-styles' @@ -210,6 +211,7 @@ export function MobileSessionCommandDock({ controller }: { controller: MobileSes } void handleAccessoryKey(createTerminalLiveAccessoryInput(key)) }} + onLongPress={key.repeatable ? keepHeldPressThroughLongPress : undefined} accessibilityLabel={key.accessibilityLabel ?? `Send ${key.label}`} > { { type: 'tool-result', output: 'important output' } ]) }) - - it('keeps a hidden interruption from authorizing a later result', () => { - const folded = foldMobileNativeChatMessages([ - toolCall('c1'), - { - id: 'interrupt', - role: 'user', - blocks: [{ type: 'text', text: '[Request interrupted by user]' }], - timestamp: 1, - source: 'transcript' - }, - toolResult('orphan', 'stale output') - ]) - - expect(folded.map((message) => message.id)).toEqual(['c1']) - expect(folded[0]?.blocks).toEqual([ - { type: 'tool-call', name: 'Bash', input: { command: 'command -v orca-ide' } } - ]) - }) }) // Claude consumes a mid-turn send through a `queued_command` attachment and writes diff --git a/mobile/src/session/mobile-structured-queued-message-cards.test.ts b/mobile/src/session/mobile-structured-queued-message-cards.test.ts index 2c6173b54a5a..44d1e0b9729f 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -239,6 +239,28 @@ describe('mobileQueuedMessageCards', () => { expect(card?.caption).toBe('Words for a kind a newer host added.') }) + it("shows the host's sentence for a fact this build cannot read all of, as the desktop card", () => { + const reason = "Claude couldn't start. Start a new chat to continue." + // As a newer host sends it: a known code with a reason this build doesn't know. + const newer = { + kind: 'startFailed', + refusal: { code: 'agent_session_conflict', details: { reason: 'newerReason' } } + } + const [card] = mobileQueuedMessageCards( + [ + draft({ + messageId: 'a', + state: 'returned', + returnedReason: reason, + returnedRejection: newer + }) + ], + [], + { pendingPrompt: false } + ) + expect(card?.caption).toBe(reason) + }) + it('maps the send-failed pause marker to English and an unknown marker to a plain pause', () => { const cards = mobileQueuedMessageCards( [ diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index 16393dbbf8ba..f3edb13e53c8 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -2,7 +2,7 @@ // The wire carries no hold copy on purpose: the caption is derived here from the // draft's own state plus the live facts the client already holds. -import { readAgentSessionFailureFact } from '../../../src/shared/agent-session-failure' +import { readWholeAgentSessionFailureFact } from '../../../src/shared/agent-session-failure' import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice' import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection' @@ -37,13 +37,13 @@ function returnedCaption( if (dispatchWasWithdrawn({ dispatchState: 'rejected', reason, rejection })) { return 'Stopped before it was sent' } - // Worded as the desktop card words it: the fact decides, the reason is the fallback, and the - // card's own Send is the retry, so the words leave out sending again. + // Worded as the desktop card words it: a fact read whole decides, the host's reason is the + // fallback, and the card's own Send is the retry, so the words leave out sending again. return agentSessionWriteNoticeEnglish( structuredAgentSessionAttemptFailureParts( { kind: 'rejected', reason }, { retryControl: true }, - readAgentSessionFailureFact(rejection) + readWholeAgentSessionFailureFact(rejection) ) ) } diff --git a/mobile/src/session/use-mobile-structured-agent-session-retry-rows.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-retry-rows.test.tsx new file mode 100644 index 000000000000..ee0ed68bda6d --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session-retry-rows.test.tsx @@ -0,0 +1,120 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, expect, it, vi } from 'vitest' +import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' +import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { getItem: vi.fn(async () => null), setItem: vi.fn(), removeItem: vi.fn() } +})) + +function retry(sequence: number, attempt: number): AgentJournalRenderItem { + return { + itemId: `retry-${sequence}`, + revision: 1, + sequence, + observedAt: sequence, + body: { + kind: 'status', + tone: 'warning', + ...agentSessionFailureWords( + { + kind: 'providerRetrying', + detail: { text: `Reconnecting... ${attempt}/5`, audience: 'person' }, + retry: { cause: 'stream disconnected before completion' } + }, + { surface: 'row', agentName: 'Codex' } + ) + } + } +} + +function snapshot(items: AgentJournalRenderItem[]): AgentSessionSubscribeEvent { + const newest = items.length + return { + type: 'snapshot', + sessionId: 'session-1', + fence: 3, + page: { + sessionId: 'session-1', + epoch: 'epoch-1', + fence: 3, + direction: 'tail', + items, + removedItemIds: [], + submissions: [], + window: { + oldest: { epoch: 'epoch-1', sequence: 1 }, + newest: { epoch: 'epoch-1', sequence: newest }, + nextCursor: { epoch: 'epoch-1', sequence: newest + 1 } + }, + liveCursor: { epoch: 'epoch-1', sequence: newest }, + hasOlder: false, + hasNewer: false + } + } +} + +// Module scope: the harness assigns these, which a test body's own `let` would narrow to null. +let renderer: ReactTestRenderer | null = null +let hook: ReturnType | null = null +let listener: ((value: unknown) => void) | null = null +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + hook = null + listener = null +}) + +it('draws one row for a run of provider retries, with what failed on its second line', async () => { + const client: RpcClient = { + sendRequest: async () => ({ + id: 'request-1', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-1' } + }), + subscribe: (_method, _params, onData) => { + listener = onData + return () => {} + }, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } + function Harness(): null { + hook = useMobileStructuredAgentSession({ + client, + sessionId: 'session-1', + sourceIdentity: 'host-a\0workspace-a', + enabled: true, + connected: true, + agent: 'codex', + hostSupport: null, + onSendError: vi.fn() + }) + return null + } + + await act(async () => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(snapshot([retry(1, 1), retry(2, 2), retry(3, 3)]))) + + expect(hook?.session.messages.map((message) => message.blocks)).toEqual([ + [ + expect.objectContaining({ + type: 'text', + text: 'Codex is retrying: Reconnecting... 3/5.\nstream disconnected before completion' + }) + ] + ]) +}) diff --git a/mobile/src/session/use-mobile-structured-agent-turn-timing.test.tsx b/mobile/src/session/use-mobile-structured-agent-turn-timing.test.tsx index bc14028c872d..9795c0085405 100644 --- a/mobile/src/session/use-mobile-structured-agent-turn-timing.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-turn-timing.test.tsx @@ -115,7 +115,7 @@ describe('useMobileStructuredAgentTurnTiming', () => { expect(timing?.workingStartedAt).toBe(CLIENT_NOW - 2_500) // The row's provider key resolves through the submission alias, not journal order. expect([...timing!.settledTurns]).toEqual([ - ['orca:first', { startedAt: HOST_START, workedSeconds: 61 }], + ['orca:first', { startedAt: HOST_START, workedSeconds: 61, verdict: 'interruption' }], ['u2', null] ]) diff --git a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx index 8369ff03a171..9a0fb4f3a6c6 100644 --- a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx +++ b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx @@ -2,7 +2,10 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' -import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' +import type { + AgentSessionSubscribeEvent, + AgentSessionTurnActivity +} from '../../../src/shared/agent-session-wire' import type { RpcClient } from '../transport/rpc-client' import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' @@ -13,12 +16,17 @@ function journalItem( return { itemId: `item-${sequence}`, revision: 1, sequence, observedAt: sequence, body } } -function snapshot(items: AgentJournalRenderItem[], fence: number): AgentSessionSubscribeEvent { +function snapshot( + items: AgentJournalRenderItem[], + fence: number, + activity?: AgentSessionTurnActivity +): AgentSessionSubscribeEvent { const newest = items.length return { type: 'snapshot', sessionId: 'session-1', fence, + ...(activity ? { activity } : {}), page: { sessionId: 'session-1', epoch: 'epoch-1', @@ -36,7 +44,7 @@ function snapshot(items: AgentJournalRenderItem[], fence: number): AgentSessionS hasOlder: false, hasNewer: false } - } as AgentSessionSubscribeEvent + } } /** What the one live indicator row reads, resolved off the session journal. */ @@ -126,13 +134,39 @@ describe('useMobileStructuredAgentSession turn indicator', () => { input: { command: 'pnpm lint' }, state: 'running' }), - journalItem(4, { kind: 'status', text: 'Updating the plan' }) + journalItem(4, { kind: 'status', text: 'Context compacted' }) ], - 3 + 3, + { turnId: 'turn-1', text: 'Updating the plan' } ) ) }) expect(hook?.turnIndicator).toEqual({ thinking: false, activityText: 'Updating the plan' }) }) + + it('never reads a journal status row as the live activity', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).not.toBeNull()) + + act(() => { + listener?.( + snapshot( + [ + runningTurn, + journalItem(2, { + kind: 'status', + tone: 'warning', + text: 'Claude hit a temporary problem and is retrying.' + }) + ], + 3 + ) + ) + }) + + expect(hook?.turnIndicator).toEqual({ thinking: false, activityText: null }) + }) }) diff --git a/mobile/src/tasks/composer-linked-work-item.test.ts b/mobile/src/tasks/composer-linked-work-item.test.ts index f3ce9299e922..faf5d80cd73e 100644 --- a/mobile/src/tasks/composer-linked-work-item.test.ts +++ b/mobile/src/tasks/composer-linked-work-item.test.ts @@ -1,7 +1,4 @@ import { describe, expect, it } from 'vitest' -import type { GitHubWorkItem } from '../../../src/shared/github/work-item-types' -import type { GitLabWorkItem } from '../../../src/shared/gitlab-types' -import type { LinearIssue } from '../../../src/shared/linear/issue-types' import { buildGitHubLinkedWorkItem, buildGitLabLinkedWorkItem, @@ -240,6 +237,3 @@ describe('resolveComposerBranchPick', () => { expect(pick.base.branchNameOverride).toBeUndefined() }) }) - -// Keep the exported type aliases referenced so the module surface stays covered. -export type _Ref = [GitHubWorkItem, GitLabWorkItem, LinearIssue] diff --git a/mobile/src/tasks/workspace-source-reply-schema.test.ts b/mobile/src/tasks/workspace-source-reply-schema.test.ts index 30597e8e5368..74ccecfbd041 100644 --- a/mobile/src/tasks/workspace-source-reply-schema.test.ts +++ b/mobile/src/tasks/workspace-source-reply-schema.test.ts @@ -256,15 +256,6 @@ describe('the orca.yaml hooks require nothing', () => { }) describe('sparse presets', () => { - it('reads the recorded preset, which carries no repoId or timestamps', () => { - const parsed = repoSparsePresetListSchema.safeParse({ - presets: [{ id: 'p1', name: 'docs', directories: ['docs'] }] - }) - expect(parsed.success && parsed.data).toEqual([ - { id: 'p1', name: 'docs', directories: ['docs'] } - ]) - }) - it('drops a preset with no id, which the picker could not select', () => { const parsed = repoSparsePresetListSchema.safeParse({ presets: [{ name: 'docs' }] }) expect(parsed.success && parsed.data).toEqual([]) diff --git a/mobile/src/transport/mobile-endpoint-supervisor-nudge.test.ts b/mobile/src/transport/mobile-endpoint-supervisor-nudge.test.ts index 86ea50e5c452..6fd90b8f86d3 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-nudge.test.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-nudge.test.ts @@ -136,20 +136,6 @@ describe('mobile endpoint supervisor nudges', () => { supervisor.stop() }) - it('does not suspend a relay from one focus RPC failure', async () => { - const logical = new FakeLogicalClient('connected', 'relay') - const deps = dependencies() - const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) - await supervisor.start() - - supervisor.nudge('focus') - await vi.advanceTimersByTimeAsync(0) - expect(logical.suspendActiveSession).not.toHaveBeenCalled() - expect(deps.openRelay).not.toHaveBeenCalled() - expect(logical.getState()).toBe('connected') - supervisor.stop() - }) - it('queues a network replacement that lands while another dial owns the mutex', async () => { const logical = new FakeLogicalClient('disconnected', 'lan') let resolveWrite: (() => void) | null = null diff --git a/mobile/src/transport/mobile-web-bundle-compat.test.ts b/mobile/src/transport/mobile-web-bundle-compat.test.ts index 4fea3dab1767..5a8f63501de3 100644 --- a/mobile/src/transport/mobile-web-bundle-compat.test.ts +++ b/mobile/src/transport/mobile-web-bundle-compat.test.ts @@ -55,12 +55,6 @@ describe('evaluateMobileWebBundleCompat', () => { }) }) - it('separates permission to fetch a manifest from permission to open one', () => { - // Why: both are `ok`, and a caller that mounted on the first would mount an unchecked bundle. - expect(evaluate({ manifest: null })).toEqual({ kind: 'ok', manifestChecked: false }) - expect(evaluate({})).toEqual({ kind: 'ok', manifestChecked: true }) - }) - it('blocks a host that ships no bundle', () => { expect(evaluate({ hostCapabilities: ['browser.screencast.v1'] })).toEqual({ kind: 'blocked', diff --git a/mobile/src/transport/relay-host-signed-out-verdict.test.ts b/mobile/src/transport/relay-host-signed-out-verdict.test.ts index e27e241706c3..ce0f01844eed 100644 --- a/mobile/src/transport/relay-host-signed-out-verdict.test.ts +++ b/mobile/src/transport/relay-host-signed-out-verdict.test.ts @@ -234,40 +234,4 @@ describe('classifyConnection with a signed-out desktop', () => { classifyConnection({ ...base, state: 'reconnecting', reconnectAttempts: 20 }).label ).toBe(SIGNED_OUT_LABEL) }) - - it('reads as stale once this session had been connected', () => { - expect( - classifyConnection({ ...base, state: 'reconnecting', lastConnectedAt: 1, nowMs: 2 }).reason - ).toBe('stale') - }) - - // A Tailscale endpoint cannot make "sign in on your desktop" better advice. - it('never appends the Tailscale hint', () => { - expect( - classifyConnection({ ...base, state: 'reconnecting', endpoint: '100.64.0.1' }) - ).not.toHaveProperty('hint') - }) - - it('never outranks a connected session', () => { - expect(classifyConnection({ ...base, state: 'connected' }).label).toBe('Connected') - }) - - // Re-pairing, not signing in, is the remedy when the pairing itself is dead. - it('never outranks a revoked pairing', () => { - expect(classifyConnection({ ...base, state: 'reconnecting', pairingRejected: true }).kind).toBe( - 'auth-failed' - ) - }) - - it('leaves every other verdict alone when the desktop is not signed out', () => { - expect( - classifyConnection({ - state: 'connecting', - reconnectAttempts: 0, - lastConnectedAt: null, - pendingPath: 'relay', - relayHostReachability: 'connecting' - }).label - ).toBe('Connecting via Relay…') - }) }) diff --git a/mobile/src/transport/rpc-client-stream-registry.test.ts b/mobile/src/transport/rpc-client-stream-registry.test.ts index 48c62af478d6..c58b3d938f7b 100644 --- a/mobile/src/transport/rpc-client-stream-registry.test.ts +++ b/mobile/src/transport/rpc-client-stream-registry.test.ts @@ -275,26 +275,6 @@ describe('RpcClientStreamRegistry', () => { }) }) - it('keeps a disposed browser tombstone until ready can be unsubscribed', () => { - const { registry, sent } = createRegistry() - const dispose = registry.subscribe('browser.screencast', { page: 'page-1' }, () => {}) - const request = sent[0]! - - dispose() - expect(sent).toHaveLength(1) - - registry.handleResponse( - streamingResponse(request.id, { - type: 'ready', - subscriptionId: 'browser-screencast:page-1:test' - }) - ) - expect(sent[1]).toMatchObject({ - method: 'browser.screencast.unsubscribe', - params: { subscriptionId: 'browser-screencast:page-1:test' } - }) - }) - it('releases a replayed browser stream replaced by a new one before its ready', () => { const { registry, sent } = createRegistry() registry.subscribe('browser.screencast', { page: 'page-1' }, () => {}) diff --git a/mobile/src/worktree/agent-row-display.test.ts b/mobile/src/worktree/agent-row-display.test.ts index 47898017ff8e..c3fa8c84b6de 100644 --- a/mobile/src/worktree/agent-row-display.test.ts +++ b/mobile/src/worktree/agent-row-display.test.ts @@ -4,7 +4,7 @@ import { agentMainAgentVerdict, agentVerdictDisplayMark } from '../../../src/shared/agent-main-agent-verdict' -import { AGENT_JOURNAL_TURN_OUTCOMES } from '../../../src/shared/agent-turn-outcome' +import { AGENT_TURN_OUTCOMES } from '../../../src/shared/agent-turn-outcome' import { AGENT_STATUS_STALE_AFTER_MS, agentDisplayLabel, @@ -16,7 +16,7 @@ import { formatTimeAgo } from './agent-row-display' -type Outcome = (typeof AGENT_JOURNAL_TURN_OUTCOMES)[number] +type Outcome = (typeof AGENT_TURN_OUTCOMES)[number] const mainAgentDone = (outcome: Outcome, stateStartedAt = 0) => ({ mainAgent: { state: 'done' as const, outcome, stateStartedAt } }) @@ -50,19 +50,48 @@ describe('agentDotState', () => { expect(agentDotState(row({ state: 'unknown-state' as never }), 0)).toBe('idle') }) - it('reports the verdict of a done row: failed, interrupted, or an old host legacy flag', () => { + it("reports the verdict of a done row: failed, or a user's Stop (also an old host's flag) as interrupted", () => { expect(agentDotState(row({ state: 'done', interrupted: true }), 0)).toBe('interrupted') expect(agentDotState(row({ state: 'done', ...mainAgentDone('failure') }), 0)).toBe('failed') expect( agentDotState(row({ state: 'done', ...mainAgentDone('cancellation'), interrupted: true }), 0) ).toBe('interrupted') expect(agentDotState(row({ state: 'done', ...mainAgentDone('success') }), 0)).toBe('done') + // A turn a newer request replaced reads as a Stop does. + expect(agentDotState(row({ state: 'done', ...mainAgentDone('superseded') }), 0)).toBe( + 'interrupted' + ) + }) + + it('reads a crash-cut turn as failed and an unproven end as unconfirmed', () => { + expect(agentDotState(row({ state: 'done', ...mainAgentDone('interruption') }), 0)).toBe( + 'failed' + ) + expect(agentDisplayLabel(row({ state: 'done', ...mainAgentDone('interruption') }), 0)).toBe( + 'Failed' + ) + expect(agentDotState(row({ state: 'done', ...mainAgentDone('unconfirmed') }), 0)).toBe( + 'unconfirmed' + ) + expect(agentDisplayLabel(row({ state: 'done', ...mainAgentDone('unconfirmed') }), 0)).toBe( + 'Couldn’t confirm' + ) + }) + + // Rows arrive unparsed, so an arm a newer host adds must read as the done it always did. + it('reads a done row carrying an outcome it cannot name as done', () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an arm from a newer host, which the unparsed wire row can carry. + const future = mainAgentDone('from-a-newer-host' as Outcome) + expect(agentDotState(row({ state: 'done', ...future }), 0)).toBe('done') }) it('shows a main agent that failed while its subagents still run as failed', () => { expect(agentDotState(row({ state: 'working', ...mainAgentDone('failure') }), 0)).toBe('failed') expect(agentDotState(row({ state: 'waiting', ...mainAgentDone('failure') }), 0)).toBe('failed') - // Only a failure outranks live work; a success or a stop with live subagents reads working. + expect(agentDotState(row({ state: 'working', ...mainAgentDone('interruption') }), 0)).toBe( + 'failed' + ) + // Only a fault outranks live work; a success or a stop with live subagents reads working. expect(agentDotState(row({ state: 'working', ...mainAgentDone('success') }), 0)).toBe('working') expect( agentDotState( @@ -78,7 +107,7 @@ describe('agentDotState', () => { const mainAgents = [ undefined, ...states.flatMap((state) => - [undefined, ...AGENT_JOURNAL_TURN_OUTCOMES].map((outcome) => ({ + [undefined, ...AGENT_TURN_OUTCOMES].map((outcome) => ({ state, ...(outcome ? { outcome } : {}), stateStartedAt: 0 @@ -112,9 +141,9 @@ describe('agentDotState', () => { ).toBe('working') // 'done' never decays, and neither does its verdict. expect(agentDotState(row({ state: 'done', updatedAt: 0 }), stale)).toBe('done') - expect(agentDotState(row({ state: 'done', updatedAt: 0, interrupted: true }), stale)).toBe( - 'interrupted' - ) + expect( + agentDotState(row({ state: 'done', updatedAt: 0, ...mainAgentDone('interruption') }), stale) + ).toBe('failed') }) }) diff --git a/mobile/src/worktree/agent-row-display.ts b/mobile/src/worktree/agent-row-display.ts index da93aa392207..87b07abfbfb7 100644 --- a/mobile/src/worktree/agent-row-display.ts +++ b/mobile/src/worktree/agent-row-display.ts @@ -1,5 +1,5 @@ import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' -import type { AgentJournalTurnOutcome } from '../../../src/shared/agent-turn-outcome' +import type { AgentTurnOutcome } from '../../../src/shared/agent-turn-outcome' // Mirrors the desktop AGENT_STATUS_STALE_AFTER_MS (src/shared/agent-status-types.ts: // 30 min). Defined locally rather than imported because a runtime-value import @@ -19,26 +19,55 @@ export type AgentDotState = | 'idle' | 'interrupted' | 'failed' + | 'unconfirmed' type AgentRowVerdictSource = Pick +// Mirrors AGENT_TURN_OUTCOMES (src/shared/agent-turn-outcome.ts), which mobile cannot import as a +// value (see AGENT_STATUS_STALE_AFTER_MS above). The rows arrive unparsed, so a newer host's arm +// has to read as no verdict here. +const AGENT_TURN_OUTCOMES: readonly AgentTurnOutcome[] = [ + 'success', + 'failure', + 'cancellation', + 'superseded', + 'interruption', + 'unconfirmed' +] + // Mirrors desktop agentMainAgentVerdict and agentVerdictDisplayMark // (src/shared/agent-main-agent-verdict.ts); a parity test runs both over one table. `mainAgent` is // the main agent's own status, sent also while subagents hold the row working; an old host sends none. -export function agentRowVerdict(row: AgentRowVerdictSource): AgentJournalTurnOutcome | null { +export function agentRowVerdict(row: AgentRowVerdictSource): AgentTurnOutcome | null { if (row.mainAgent && row.mainAgent.state !== 'done') { return null } - return row.mainAgent?.outcome ?? (row.state === 'done' && row.interrupted ? 'cancellation' : null) + const outcome = row.mainAgent?.outcome + if (outcome !== undefined) { + return AGENT_TURN_OUTCOMES.find((known) => known === outcome) ?? null + } + return row.state === 'done' && row.interrupted ? 'cancellation' : null } -// A failure outranks every state; a stop marks only a row that is itself done. -export function agentRowVerdictMark(row: AgentRowVerdictSource): 'failed' | 'interrupted' | null { - const verdict = agentRowVerdict(row) - if (verdict === 'failure') { - return 'failed' +// A fault (a failure, or a turn cut short by something other than the user) reads failed and +// outranks every state; a user's Stop, a turn a newer request replaced, and an unproven end mark +// only a row that is itself done. +export function agentRowVerdictMark( + row: AgentRowVerdictSource +): 'failed' | 'interrupted' | 'unconfirmed' | null { + switch (agentRowVerdict(row)) { + case 'failure': + case 'interruption': + return 'failed' + case 'cancellation': + case 'superseded': + return row.state === 'done' ? 'interrupted' : null + case 'unconfirmed': + return row.state === 'done' ? 'unconfirmed' : null + case 'success': + case null: + return null } - return verdict === 'cancellation' && row.state === 'done' ? 'interrupted' : null } export function agentDotState( @@ -85,6 +114,8 @@ export function agentStateLabel(state: AgentDotState): string { return 'Interrupted' case 'failed': return 'Failed' + case 'unconfirmed': + return 'Couldn’t confirm' case 'done': return 'Done' case 'idle': diff --git a/mobile/tests-typecheck-baseline.txt b/mobile/tests-typecheck-baseline.txt index ab63d955f7ad..9d47b7ab801d 100644 --- a/mobile/tests-typecheck-baseline.txt +++ b/mobile/tests-typecheck-baseline.txt @@ -115,7 +115,6 @@ src/transport/mobile-relay-rpc-session.test.ts src/transport/mobile-relay-runtime-failover.test.ts src/transport/pairing-relay-candidate.test.ts src/transport/pre-profile-pairing-coordinator.test.ts -src/transport/relay-host-signed-out-verdict.test.ts src/transport/rpc-client-synthesized-close-diagnostics.test.ts src/transport/rpc-operation.test.ts src/transport/rpc-session-liveness-watchdog.test.ts diff --git a/mobile/web-entry/web-overrides.json b/mobile/web-entry/web-overrides.json index 62ad176c57d2..c0c3bcb43757 100644 --- a/mobile/web-entry/web-overrides.json +++ b/mobile/web-entry/web-overrides.json @@ -201,6 +201,10 @@ "file": "src/notifications/desktop-notification-channel.web.ts", "reason": "The page creates no Android notification channel and the native file already returns early off Android, so what this sibling changes is the import of expo-notifications, whose import-time read breaks the document for the reason push-token.web.ts beside it gives." }, + { + "file": "src/navigation/host-stack.web.tsx", + "reason": "expo-router's Stack renders @react-navigation/native-stack's web view on the page, which flips each screen's display and ignores the animation option, so pushing the session from the worktree list and Back were instant cuts where the native stack slides. This one keeps a popped screen mounted until it has slid out." + }, { "file": "src/app-update/use-wall-app-update.web.ts", "reason": "The update checker runs in the app, not the page. The native file hands the compatibility wall the release that check found; this one offers none, so the wall inside the page keeps its store or releases link and the page bundle never carries the checker." diff --git a/package.json b/package.json index 0b41ed3980ac..a64d7e212ad8 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "audit:perf": "oxlint --config config/oxlint-performance-audit.json --format json src", "test:perf:contracts": "vitest run --config config/vitest.performance.config.ts", "format": "oxfmt --write .", - "lint": "oxlint && pnpm run audit:anti-slop && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:readme-local-links && pnpm run verify:rpc-params-catalog && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalogs && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage", + "lint": "oxlint && pnpm run audit:anti-slop && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:readme-local-links && pnpm run check:node-runtime-pin && pnpm run verify:rpc-params-catalog && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalogs && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage", "audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor", "audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings", "audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings", @@ -30,7 +30,7 @@ "lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs", "prepare": "husky", "test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts", - "test:bun:profile": "node config/scripts/run-bun-profile-tests.mjs", + "test:node-server": "node config/scripts/run-node-server-tests.mjs", "test:skill-sharing:release": "vitest run --config config/vitest.config.ts src/main/skills src/main/runtime/rpc/methods/skills.test.ts src/relay/skill-install-handler.test.ts src/shared/skill-bundle-install-contract.test.ts src/shared/skill-install-contract.test.ts src/shared/skill-install-failure.test.ts src/shared/skill-package-manifest.test.ts", "test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs", "capture:agent-transcript": "node config/scripts/ensure-native-runtime.mjs --runtime=node && node config/scripts/capture-agent-pty-transcript.mjs", @@ -39,7 +39,10 @@ "check:ts-nocheck-ratchet": "node config/scripts/check-ts-nocheck-ratchet.mjs", "check:runtime-electron-ratchet": "node config/scripts/check-runtime-electron-ratchet.mjs", "check:readme-local-links": "node config/scripts/check-readme-local-links.mjs", - "build:orcad": "node config/scripts/build-orcad-bun.mjs", + "check:daemon-protocol-crossing": "node config/scripts/check-daemon-protocol-crossing.mjs", + "check:runtime-launcher-protocol-ratchet": "node config/scripts/check-runtime-launcher-protocol-ratchet.mjs", + "check:node-runtime-pin": "node config/scripts/check-node-runtime-pin.mjs", + "build:orcad": "node config/scripts/build-orcad-node.mjs", "build:orcad-template": "node config/scripts/build-orcad-template.mjs", "build:orcad-prebuilds": "node config/scripts/build-orcad-prebuilds.mjs", "smoke:orcad-terminal": "node config/scripts/ensure-native-runtime.mjs --runtime=node && pnpm run build:cli && pnpm run build:orcad && node config/scripts/runtime-serve-terminal-smoke.mjs --target orcad", @@ -191,7 +194,7 @@ "proper-lockfile": "4.1.2", "qrcode": "^1.5.4", "react-i18next": "17.0.15", - "serve-sim": "0.1.40", + "serve-sim": "0.1.47", "sherpa-onnx": "1.12.37", "ssh2": "^1.17.0", "tldts": "7.4.14", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b5c4b890d44..17332ca0bb19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,8 +176,8 @@ importers: specifier: 17.0.15 version: 17.0.15(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2) serve-sim: - specifier: 0.1.40 - version: 0.1.40(typescript@7.0.2) + specifier: 0.1.47 + version: 0.1.47(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2) sherpa-onnx: specifier: 1.12.37 version: 1.12.37 @@ -6930,9 +6930,9 @@ packages: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} - serve-sim@0.1.40: - resolution: {integrity: sha512-32JSUriN/EnNR3zx6fmiOKjuFrbA8VHWR2J3dX8T51Z/xkSmrSLOwaz/3W9lPiCIaF2O3LT4qpAhLkp0f1kVaw==} - engines: {node: '>=18'} + serve-sim@0.1.47: + resolution: {integrity: sha512-zDibExbPY6x6Oo173vwtx6zrvVG3464ReC6Q+DxlLqzhL5gmPVJWBwXSh5/BpaYI4PzszaRwL3N47Vjc3lDy9w==} + engines: {node: '>=20'} hasBin: true serve-static@2.2.1: @@ -14368,11 +14368,17 @@ snapshots: type-fest: 0.13.1 optional: true - serve-sim@0.1.40(typescript@7.0.2): + serve-sim@0.1.47(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2): dependencies: inspect-webkit: 0.0.5(typescript@7.0.2) + sonner: 2.0.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + ws: 8.21.3 transitivePeerDependencies: + - bufferutil + - react + - react-dom - typescript + - utf-8-validate serve-static@2.2.1(supports-color@7.2.0): dependencies: diff --git a/skill-guides/orchestration.md b/skill-guides/orchestration.md index a7d63ff922cf..0fe7628aa926 100644 --- a/skill-guides/orchestration.md +++ b/skill-guides/orchestration.md @@ -49,8 +49,9 @@ non-Orca subagent tool when Orca orchestration provenance was requested. place workers. A Task is work. A Dispatch is one authoritative Task attempt. - Lifecycle authority comes from the active Dispatch, not a terminal title, copied ID, old database row, provider transcript, or visible pane. -- Workers use the exact executable, handle, capability, Task ID, and Dispatch ID - in the live preamble. Never reconstruct, translate, or broaden those arguments. +- Workers use the exact executable, handle, Task ID, and Dispatch ID in the live + preamble, plus any other flag it carries. Never reconstruct, translate, or + broaden those arguments. - After remote start, address the worker by Dispatch ID. The execution host owns process, filesystem, transcript, stop, and cleanup facts. Preserve the verdicts `live` / `unverifiable` / `exited`; contact loss is not process death. diff --git a/skill-guides/orchestration/references/messaging-and-gates.md b/skill-guides/orchestration/references/messaging-and-gates.md index 573ca90e5e79..a05ccf7786cf 100644 --- a/skill-guides/orchestration/references/messaging-and-gates.md +++ b/skill-guides/orchestration/references/messaging-and-gates.md @@ -36,7 +36,7 @@ ORCA orchestration send --to dispatch: --subject "Follow-up" --body Do not substitute a remote terminal handle. Omit `--from` for ordinary coordinator calls; a dispatched worker instead copies the exact `--from` and -capability arguments in its preamble. `check` is the exception: it identifies +other arguments in its preamble. `check` is the exception: it identifies its caller with `--terminal`, never `--from`. Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, diff --git a/skill-guides/orchestration/references/recovery-and-cleanup.md b/skill-guides/orchestration/references/recovery-and-cleanup.md index 07cf9fbc7b73..5664f2c88ae8 100644 --- a/skill-guides/orchestration/references/recovery-and-cleanup.md +++ b/skill-guides/orchestration/references/recovery-and-cleanup.md @@ -131,7 +131,18 @@ ORCA orchestration worker-abandon --dispatch --json `worker-stop` closes only the exact proven supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes. `worker-abandon` fences orchestration while accepting that resources may remain -live; it performs no remote, process, or filesystem action. +live; it performs no remote, process, or filesystem action. When it settles a +worker, it retains an owned terminal by the same rule as `worker-retain`, so Orca +stops owing its release; a release already committed (`releasing`, +`release_unknown`) is left as it is. An already-settled worker is left untouched. + +To cancel a Task, settle its worker with `worker-stop` or `worker-abandon`, then +record the reason. A cancelled Task is `failed`, so its dependents stay blocked +until a `--retry-of` replacement completes it: + +```text +ORCA orchestration task-update --id --status failed --result cancelled --json +``` ## Retain and release diff --git a/skill-guides/orchestration/references/worker-contract.md b/skill-guides/orchestration/references/worker-contract.md index 6e35da7b8f9d..a2315b7ceeee 100644 --- a/skill-guides/orchestration/references/worker-contract.md +++ b/skill-guides/orchestration/references/worker-contract.md @@ -2,7 +2,8 @@ The injected preamble is authoritative. Copy its command rather than reconstructing flags. In particular, preserve the exact executable, worker -handle, Dispatch capability, Task ID, and Dispatch ID. +handle, Task ID, and Dispatch ID, and keep any other flag it carries (an older +Orca host adds `--dispatch-capability`). ## Heartbeat @@ -10,7 +11,7 @@ Send heartbeats only at the cadence required by the live preamble. Skip them while blocked inside `ask` or `check --wait`; those calls are liveness signals. ```text -ORCA orchestration send --from --dispatch-capability --type heartbeat --subject "alive" --task-id --dispatch-id --phase "" +ORCA orchestration send --from --type heartbeat --subject "alive" --task-id --dispatch-id --phase "" ``` Use typed lifecycle flags, not a hand-written JSON payload. A heartbeat proves @@ -22,9 +23,9 @@ Use Orca `ask` whenever the coordinator must answer. Never open a local question TUI the coordinator cannot answer. ```text -ORCA orchestration ask --from --dispatch-capability --question "" --options "," --timeout-ms 600000 +ORCA orchestration ask --from --question "" --options "," --timeout-ms 600000 -ORCA orchestration ask --from --dispatch-capability --resume --timeout-ms 600000 +ORCA orchestration ask --from --resume --timeout-ms 600000 ``` A timeout or disconnect leaves the original question pending. Resume its @@ -49,13 +50,15 @@ If `check` returns `consumer_fenced`, this process no longer owns its Dispatch: the Attempt was re-attached to another worker or settled without you. Stop, do not send `worker_done`, and do not retry the check. An empty `check` never means you were replaced; `consumer_fenced` is the only way you learn that. +If `send` or `ask` returns `consumer_fenced`, the command ran from another +party's terminal (a coordinator or another worker); run it from your own terminal. ## Escalation Escalate only before completion and only when the coordinator must intervene: ```text -ORCA orchestration send --from --dispatch-capability --type escalation --subject "Blocked: " --body "
" --task-id --dispatch-id +ORCA orchestration send --from --type escalation --subject "Blocked: " --body "
" --task-id --dispatch-id ``` ## Completion @@ -68,7 +71,7 @@ Append `--files-modified` or `--report-path` only when applicable, using actual paths. Do not send documentation placeholders as metadata. ```text -ORCA orchestration send --from --dispatch-capability --type worker_done --subject "" --body "" --task-id --dispatch-id --outcome succeeded +ORCA orchestration send --from --type worker_done --subject "" --body "" --task-id --dispatch-id --outcome succeeded ``` After `worker_done`, end the dispatched turn and idle. Do not poll, close your diff --git a/src/cli/bundled-skill-guides.ts b/src/cli/bundled-skill-guides.ts index 0985a2239ab9..53146931176d 100644 --- a/src/cli/bundled-skill-guides.ts +++ b/src/cli/bundled-skill-guides.ts @@ -66,10 +66,10 @@ const ORCA_PER_WORKSPACE_ENV_SSH_HOST_REFERENCE_MARKDOWN = "# SSH connection mod const ORCA_PER_WORKSPACE_ENV_WINDOWS_SCRIPTS_REFERENCE_MARKDOWN = "# Windows local-side scripts\n\nLoad this when the user's desktop is Windows and you are scaffolding the local-side scripts. A bare\n`.sh` will not execute there. Either require WSL or Git Bash and point `orca.yaml` at a launcher such\nas `bash ./scripts/orca-vm/.sh` through a `.cmd` file, or scaffold PowerShell equivalents.\n\nThe remote-side commands you run inside the Linux environment stay bash regardless of the desktop OS.\n\n```powershell\n#requires -Version 5\n$ErrorActionPreference = 'Stop'\n# resolve env→state→fallback; run the provider CLI / ssh the same way;\n# capture provider output; build the result object for the chosen mode and write ONE line of JSON to stdout.\n# Orca-server mode: @{ schemaVersion=1; pairingCode=$pairingCode; projectRoot=$projectRoot; userData=@{...} }\n# SSH mode: @{ schemaVersion=1; connection=@{ type=\"ssh\"; projectRoot=$projectRoot;\n# target=@{ label=$label; host=$host; port=$port; username=$user } } }\n($result | ConvertTo-Json -Compress -Depth 6)\n# progress/errors → Write-Error / the error stream, never stdout.\n```\n\nThe doctor's executable-bit check is a POSIX concept and is skipped on Windows, so a script that is\nunusable on the user's machine for a different reason still has to be caught by the `--provision`\nself-test.\n" // oxfmt-ignore -const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Coordinate supervised Orca workers: threaded messages, blocking ask/reply,\n task dispatch, worker_done/escalation waits, task DAGs, decision gates,\n coordinator loops, and decomposing work across agents. Use `orca-cli` for full\n ownership handoffs — \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", \"another worktree\" — unless asked to supervise, monitor, or coordinate\n a DAG, and for terminal control, lightweight terminal prompts, shell commands,\n Orca worktree management, and reading or waiting on terminals.\n---\n\n# Orca orchestration\n\nOrchestration is Orca's structured coordination layer. It records who owns work,\nwhich attempt is authoritative, and when supervised work has settled.\n\n## Outcome\n\n**Result:** every in-scope Task has one explicit outcome and every settled worker\nterminal has a next owner or cleanup decision. **Next consumer:** the user who\nrequested supervision. **Done:** all expected Dispatches have settled, every\ndelivered message was processed before acknowledgment, each settled worker was\nreused, explicitly retained, or released, and the turn ends only when the report\nto that user names, per Task, its outcome, the evidence behind it, and any\nunresolved blocker.\n\n**Safe failure:** preserve work and authority and report the state as unknown or\n`unverifiable`. Only positive proof of exit authorizes stop, abandon, or retry,\nand only an accepted settlement authorizes release. Every other observation,\nabsence included, is a checkpoint.\n\n## Classify the role\n\n| Current context | Role | Route |\n| ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------ |\n| The user explicitly asks to supervise, monitor, wait for results, track completion, coordinate a DAG, use a decision gate, or manage ask/reply | Coordinator | Use the supervised loop below |\n| The current prompt contains a live injected preamble with Task and Dispatch IDs | Dispatched worker | Follow the preamble and the worker obligations below |\n| The user asks to hand off ownership or start another agent/worktree without supervision | Handoff owner | Use `orca-cli`; create no Run, Task, or Dispatch and do not monitor completion |\n| A message carries a legacy authority label | Compatibility operator | Load the legacy contract reference before any lifecycle mutation |\n| No live preamble and no explicit supervision | Ordinary terminal agent | Do not emit lifecycle messages; use `orca-cli` for terminal/worktree work |\n\nModel or effort selection does not make a handoff supervised. Never substitute a\nnon-Orca subagent tool when Orca orchestration provenance was requested.\n\n## Authority and safety floor\n\n- A Run is a durable namespace and coordinator inbox; it does not schedule or\n place workers. A Task is work. A Dispatch is one authoritative Task attempt.\n- Lifecycle authority comes from the active Dispatch, not a terminal title,\n copied ID, old database row, provider transcript, or visible pane.\n- Workers use the exact executable, handle, capability, Task ID, and Dispatch ID\n in the live preamble. Never reconstruct, translate, or broaden those arguments.\n- After remote start, address the worker by Dispatch ID. The execution host owns\n process, filesystem, transcript, stop, and cleanup facts. Preserve the verdicts\n `live` / `unverifiable` / `exited`; contact loss is not process death.\n- Liveness is layered: `worker-list`'s `projection.liveness` is the fleet verdict\n for the agent; `worker-show`'s `observation.status` is PTY liveness only. A live\n terminal can still hold a dead or stuck agent.\n- Folder workspaces are valid; never require Git or assume a worktree.\n- Clients and remote servers update independently. Treat unknown optional fields\n as absent. A new stream operation requires advertised capability because old\n decoders may silently drop unknown opcodes. Never fall back to local execution\n when remote authority or capability is unproven.\n- Use the executable you used to run `skills get` for the entire run. In the\n examples below, replace `ORCA` with it; do not create a shell variable or run\n `ORCA` literally. If it fails, report that exact error instead of switching.\n- A successful `orchestration send` proves durable enqueue; its wake or nudge is\n best-effort attention only and does not prove the recipient read or accepted it.\n\n## Worker obligations\n\nThe injected preamble is authoritative. A dispatched worker must:\n\n1. Do only the current Task and use the preamble's `ask` command for a blocking\n coordinator question. Never open a local question TUI the coordinator cannot\n answer. Resume the same message ID after an ask timeout.\n2. Send heartbeats only at the cadence in the preamble. A heartbeat proves\n liveness, not completion.\n3. Read coordinator follow-ups at each natural checkpoint — before starting a\n new file, after a test run — and once more immediately before `worker_done`:\n `ORCA orchestration check --terminal --json`.\n4. Send `worker_done` exactly once, from the dispatched terminal, with a\n three-sentence executive summary, both lifecycle IDs, and explicit\n `--outcome succeeded` or `--outcome failed`. Never encode failure only in prose.\n5. Append `--files-modified` and `--report-path` only with real values when\n applicable. After `worker_done`, end the dispatched turn and idle; do not poll\n or start new work.\n\nA direct user instruction after completion starts new user-owned work and takes\nprecedence over the idle rule. Do not reuse the settled lifecycle IDs.\n\n## Canonical supervised loop\n\nConfirm the runtime, bind one Run, and start the full independent wave before\nwaiting. `worker-start --spec` creates the Task and its attempt in one call:\n\n```text\nORCA status --json\nORCA orchestration run-create --objective \"\" --json\nORCA orchestration worker-start --spec \"\" --worktree current --agent codex --json\nORCA orchestration worker-start --spec \"\" --worktree current --agent claude --json\nORCA orchestration check --wait --types \"worker_done,escalation,question\" --timeout-ms 900000 --json\n```\n\nIf `worker-start` exits non-zero, do not relaunch. Read the receipt's\n`failedStage` and `residualResources`, then load\n`references/recovery-and-cleanup.md`.\n\nUse `task-create` plus `worker-start --task ` for planned fan-out with\ndependencies or a retry of a known Task. Use dependencies only for real ordering\nand prefer parallel waves over chains deeper than three or four steps; nested\nworkers obey the depth limit, and a new Run does not reset the caller's depth.\n\nA consuming `check` names its caller with `--terminal `, never `--from`;\nomit it inside the coordinator's own Orca terminal. It returns the bound Run's\noldest FIFO Delivery and replays that batch until acknowledged. Process every\nmessage: reply to questions, validate each `worker_done` against the expected\nactive Dispatch, and decide each settled terminal's next owner before the ack:\n\n```text\nORCA orchestration reply --id --body \"\" --json\nORCA orchestration worker-release --dispatch --json\nORCA orchestration check --ack --wait --types \"worker_done,escalation,question\" --timeout-ms 900000 --json\n```\n\nKeep waiting until every expected Dispatch settles. A timeout or empty result is\na checkpoint, not a failure. Do not stop, retry, release, or launch a duplicate\neditor without the positive proof `## Outcome` requires.\n\nAfter three consecutive empty waits, stop waiting blindly and enumerate with\n`ORCA orchestration worker-list --include-remote --json` (defaults to the bound\nRun; `--run ` overrides; the receipt's `scope` names which), acting on\neach row's `projection.attention` categories, `projection.attention.requiresAction`, and literal `projection.nextAction` argv.\nRows come newest first and page at 100: while `page.hasMore`, follow `page.nextCursor` with `--cursor `.\nA `none` `nextAction` has no argv to run: read `liveness.reason` and keep waiting\nwith `check --wait`. Absence never earns an argv; settlement and pending work still do.\nLeave the wait only on positive proof the agent stopped: `exited` liveness, the\nworker's own observation of process exit, or a transcript whose final agent turn\nsent no `worker_done`. Then load `references/recovery-and-cleanup.md` and choose\n`worker-stop` or `worker-abandon` explicitly. `unverifiable` is absence,\nincluding when `worker-show` reports `agentWait` null. Absence never authorizes\nstop, abandon, retry, or release; keep waiting or inspect.\n\n`worker-start` is the normal path, composing placement, terminal readiness,\nprompt injection, and supervised resource ownership. `dispatch --inject` leaves\nan operator-created process unsupervised and is only for an expressiveness gap.\n\n## Task-spec contract\n\nEvery Task spec must be self-contained and name:\n\n- **Target:** the files, component, or environment in scope.\n- **Change:** the concrete result to produce.\n- **Constraints:** invariants, compatibility rules, and do-not-touch boundaries.\n- **Ownership:** what this worker may edit and any coordination boundary.\n- **Observable acceptance:** the test, output, or evidence that proves completion.\n\n## Completion accounting\n\nAfter an accepted success or failure report, immediately do exactly one:\n\n1. Reuse the same proven agent terminal for an immediate follow-up Dispatch.\n2. Record user-requested retention with `worker-retain`.\n3. Run `worker-release`.\n\nRelease is post-settlement cleanup, not cancellation. Only an accepted\nsettlement authorizes it; no other observation does. If release is uncertain,\nfollow its exact recovery receipt and never substitute `terminal close`.\n\nA valid `worker_done` settles the Task and Dispatch automatically; do not follow\nit with `task-update --status completed`. Enumerate the terminals still owing a\ndecision with `worker-list --run --terminal-state reclaimable --json`,\nand do not end the coordinator turn until it returns none.\n\n## Conditional references\n\nThis compact guide is sufficient for the normal local loop. At an action gate\nbelow, run `ORCA skills get orchestration --reference references/.md` and\nread only that document; `--references` lists the names. If the CLI rejects\n`--reference`, run `ORCA skills get orchestration --full` once instead: it\nreturns this exact kernel and every reference, so read only the named one. If an\nolder CLI rejects `--full`, keep this kernel's safety floor, use that command's\n`--help`, and never guess newer flags.\n\n| Action gate | Bundled reference |\n| ------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |\n| Expanded DAG waves, launch model/effort, same-terminal reuse, or review ownership | `references/coordinator-loop.md` |\n| You are a dispatched worker and the live preamble does not answer your question, or `check` returned an error | `references/worker-contract.md` |\n| New worktree, exact workspace, SSH, WSL, or connected-server placement | `references/placement-and-remote.md` |\n| Inbox replay, follow-up messages, group addresses, or decision gates | `references/messaging-and-gates.md` |\n| Failed/stopped/unknown attempts, retry, stop, abandon, retain, or uncertain release | `references/recovery-and-cleanup.md` |\n| Custom argv or terminal topology that `worker-start` cannot express | `references/low-level-topology.md` |\n| Any legacy label, adopted Run, compatibility receipt, or takeover | `references/legacy-contract-migration.md` |\n\nRetired scheduler commands are not aliases for Run creation. Recovery commands\nmust provide their exact next action; follow it with the same selected executable.\n" +const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Coordinate supervised Orca workers: threaded messages, blocking ask/reply,\n task dispatch, worker_done/escalation waits, task DAGs, decision gates,\n coordinator loops, and decomposing work across agents. Use `orca-cli` for full\n ownership handoffs — \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", \"another worktree\" — unless asked to supervise, monitor, or coordinate\n a DAG, and for terminal control, lightweight terminal prompts, shell commands,\n Orca worktree management, and reading or waiting on terminals.\n---\n\n# Orca orchestration\n\nOrchestration is Orca's structured coordination layer. It records who owns work,\nwhich attempt is authoritative, and when supervised work has settled.\n\n## Outcome\n\n**Result:** every in-scope Task has one explicit outcome and every settled worker\nterminal has a next owner or cleanup decision. **Next consumer:** the user who\nrequested supervision. **Done:** all expected Dispatches have settled, every\ndelivered message was processed before acknowledgment, each settled worker was\nreused, explicitly retained, or released, and the turn ends only when the report\nto that user names, per Task, its outcome, the evidence behind it, and any\nunresolved blocker.\n\n**Safe failure:** preserve work and authority and report the state as unknown or\n`unverifiable`. Only positive proof of exit authorizes stop, abandon, or retry,\nand only an accepted settlement authorizes release. Every other observation,\nabsence included, is a checkpoint.\n\n## Classify the role\n\n| Current context | Role | Route |\n| ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------ |\n| The user explicitly asks to supervise, monitor, wait for results, track completion, coordinate a DAG, use a decision gate, or manage ask/reply | Coordinator | Use the supervised loop below |\n| The current prompt contains a live injected preamble with Task and Dispatch IDs | Dispatched worker | Follow the preamble and the worker obligations below |\n| The user asks to hand off ownership or start another agent/worktree without supervision | Handoff owner | Use `orca-cli`; create no Run, Task, or Dispatch and do not monitor completion |\n| A message carries a legacy authority label | Compatibility operator | Load the legacy contract reference before any lifecycle mutation |\n| No live preamble and no explicit supervision | Ordinary terminal agent | Do not emit lifecycle messages; use `orca-cli` for terminal/worktree work |\n\nModel or effort selection does not make a handoff supervised. Never substitute a\nnon-Orca subagent tool when Orca orchestration provenance was requested.\n\n## Authority and safety floor\n\n- A Run is a durable namespace and coordinator inbox; it does not schedule or\n place workers. A Task is work. A Dispatch is one authoritative Task attempt.\n- Lifecycle authority comes from the active Dispatch, not a terminal title,\n copied ID, old database row, provider transcript, or visible pane.\n- Workers use the exact executable, handle, Task ID, and Dispatch ID in the live\n preamble, plus any other flag it carries. Never reconstruct, translate, or\n broaden those arguments.\n- After remote start, address the worker by Dispatch ID. The execution host owns\n process, filesystem, transcript, stop, and cleanup facts. Preserve the verdicts\n `live` / `unverifiable` / `exited`; contact loss is not process death.\n- Liveness is layered: `worker-list`'s `projection.liveness` is the fleet verdict\n for the agent; `worker-show`'s `observation.status` is PTY liveness only. A live\n terminal can still hold a dead or stuck agent.\n- Folder workspaces are valid; never require Git or assume a worktree.\n- Clients and remote servers update independently. Treat unknown optional fields\n as absent. A new stream operation requires advertised capability because old\n decoders may silently drop unknown opcodes. Never fall back to local execution\n when remote authority or capability is unproven.\n- Use the executable you used to run `skills get` for the entire run. In the\n examples below, replace `ORCA` with it; do not create a shell variable or run\n `ORCA` literally. If it fails, report that exact error instead of switching.\n- A successful `orchestration send` proves durable enqueue; its wake or nudge is\n best-effort attention only and does not prove the recipient read or accepted it.\n\n## Worker obligations\n\nThe injected preamble is authoritative. A dispatched worker must:\n\n1. Do only the current Task and use the preamble's `ask` command for a blocking\n coordinator question. Never open a local question TUI the coordinator cannot\n answer. Resume the same message ID after an ask timeout.\n2. Send heartbeats only at the cadence in the preamble. A heartbeat proves\n liveness, not completion.\n3. Read coordinator follow-ups at each natural checkpoint — before starting a\n new file, after a test run — and once more immediately before `worker_done`:\n `ORCA orchestration check --terminal --json`.\n4. Send `worker_done` exactly once, from the dispatched terminal, with a\n three-sentence executive summary, both lifecycle IDs, and explicit\n `--outcome succeeded` or `--outcome failed`. Never encode failure only in prose.\n5. Append `--files-modified` and `--report-path` only with real values when\n applicable. After `worker_done`, end the dispatched turn and idle; do not poll\n or start new work.\n\nA direct user instruction after completion starts new user-owned work and takes\nprecedence over the idle rule. Do not reuse the settled lifecycle IDs.\n\n## Canonical supervised loop\n\nConfirm the runtime, bind one Run, and start the full independent wave before\nwaiting. `worker-start --spec` creates the Task and its attempt in one call:\n\n```text\nORCA status --json\nORCA orchestration run-create --objective \"\" --json\nORCA orchestration worker-start --spec \"\" --worktree current --agent codex --json\nORCA orchestration worker-start --spec \"\" --worktree current --agent claude --json\nORCA orchestration check --wait --types \"worker_done,escalation,question\" --timeout-ms 900000 --json\n```\n\nIf `worker-start` exits non-zero, do not relaunch. Read the receipt's\n`failedStage` and `residualResources`, then load\n`references/recovery-and-cleanup.md`.\n\nUse `task-create` plus `worker-start --task ` for planned fan-out with\ndependencies or a retry of a known Task. Use dependencies only for real ordering\nand prefer parallel waves over chains deeper than three or four steps; nested\nworkers obey the depth limit, and a new Run does not reset the caller's depth.\n\nA consuming `check` names its caller with `--terminal `, never `--from`;\nomit it inside the coordinator's own Orca terminal. It returns the bound Run's\noldest FIFO Delivery and replays that batch until acknowledged. Process every\nmessage: reply to questions, validate each `worker_done` against the expected\nactive Dispatch, and decide each settled terminal's next owner before the ack:\n\n```text\nORCA orchestration reply --id --body \"\" --json\nORCA orchestration worker-release --dispatch --json\nORCA orchestration check --ack --wait --types \"worker_done,escalation,question\" --timeout-ms 900000 --json\n```\n\nKeep waiting until every expected Dispatch settles. A timeout or empty result is\na checkpoint, not a failure. Do not stop, retry, release, or launch a duplicate\neditor without the positive proof `## Outcome` requires.\n\nAfter three consecutive empty waits, stop waiting blindly and enumerate with\n`ORCA orchestration worker-list --include-remote --json` (defaults to the bound\nRun; `--run ` overrides; the receipt's `scope` names which), acting on\neach row's `projection.attention` categories, `projection.attention.requiresAction`, and literal `projection.nextAction` argv.\nRows come newest first and page at 100: while `page.hasMore`, follow `page.nextCursor` with `--cursor `.\nA `none` `nextAction` has no argv to run: read `liveness.reason` and keep waiting\nwith `check --wait`. Absence never earns an argv; settlement and pending work still do.\nLeave the wait only on positive proof the agent stopped: `exited` liveness, the\nworker's own observation of process exit, or a transcript whose final agent turn\nsent no `worker_done`. Then load `references/recovery-and-cleanup.md` and choose\n`worker-stop` or `worker-abandon` explicitly. `unverifiable` is absence,\nincluding when `worker-show` reports `agentWait` null. Absence never authorizes\nstop, abandon, retry, or release; keep waiting or inspect.\n\n`worker-start` is the normal path, composing placement, terminal readiness,\nprompt injection, and supervised resource ownership. `dispatch --inject` leaves\nan operator-created process unsupervised and is only for an expressiveness gap.\n\n## Task-spec contract\n\nEvery Task spec must be self-contained and name:\n\n- **Target:** the files, component, or environment in scope.\n- **Change:** the concrete result to produce.\n- **Constraints:** invariants, compatibility rules, and do-not-touch boundaries.\n- **Ownership:** what this worker may edit and any coordination boundary.\n- **Observable acceptance:** the test, output, or evidence that proves completion.\n\n## Completion accounting\n\nAfter an accepted success or failure report, immediately do exactly one:\n\n1. Reuse the same proven agent terminal for an immediate follow-up Dispatch.\n2. Record user-requested retention with `worker-retain`.\n3. Run `worker-release`.\n\nRelease is post-settlement cleanup, not cancellation. Only an accepted\nsettlement authorizes it; no other observation does. If release is uncertain,\nfollow its exact recovery receipt and never substitute `terminal close`.\n\nA valid `worker_done` settles the Task and Dispatch automatically; do not follow\nit with `task-update --status completed`. Enumerate the terminals still owing a\ndecision with `worker-list --run --terminal-state reclaimable --json`,\nand do not end the coordinator turn until it returns none.\n\n## Conditional references\n\nThis compact guide is sufficient for the normal local loop. At an action gate\nbelow, run `ORCA skills get orchestration --reference references/.md` and\nread only that document; `--references` lists the names. If the CLI rejects\n`--reference`, run `ORCA skills get orchestration --full` once instead: it\nreturns this exact kernel and every reference, so read only the named one. If an\nolder CLI rejects `--full`, keep this kernel's safety floor, use that command's\n`--help`, and never guess newer flags.\n\n| Action gate | Bundled reference |\n| ------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |\n| Expanded DAG waves, launch model/effort, same-terminal reuse, or review ownership | `references/coordinator-loop.md` |\n| You are a dispatched worker and the live preamble does not answer your question, or `check` returned an error | `references/worker-contract.md` |\n| New worktree, exact workspace, SSH, WSL, or connected-server placement | `references/placement-and-remote.md` |\n| Inbox replay, follow-up messages, group addresses, or decision gates | `references/messaging-and-gates.md` |\n| Failed/stopped/unknown attempts, retry, stop, abandon, retain, or uncertain release | `references/recovery-and-cleanup.md` |\n| Custom argv or terminal topology that `worker-start` cannot express | `references/low-level-topology.md` |\n| Any legacy label, adopted Run, compatibility receipt, or takeover | `references/legacy-contract-migration.md` |\n\nRetired scheduler commands are not aliases for Run creation. Recovery commands\nmust provide their exact next action; follow it with the same selected executable.\n" // oxfmt-ignore -const ORCHESTRATION_FULL_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Coordinate supervised Orca workers: threaded messages, blocking ask/reply,\n task dispatch, worker_done/escalation waits, task DAGs, decision gates,\n coordinator loops, and decomposing work across agents. Use `orca-cli` for full\n ownership handoffs — \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", \"another worktree\" — unless asked to supervise, monitor, or coordinate\n a DAG, and for terminal control, lightweight terminal prompts, shell commands,\n Orca worktree management, and reading or waiting on terminals.\n---\n\n# Orca orchestration\n\nOrchestration is Orca's structured coordination layer. It records who owns work,\nwhich attempt is authoritative, and when supervised work has settled.\n\n## Outcome\n\n**Result:** every in-scope Task has one explicit outcome and every settled worker\nterminal has a next owner or cleanup decision. **Next consumer:** the user who\nrequested supervision. **Done:** all expected Dispatches have settled, every\ndelivered message was processed before acknowledgment, each settled worker was\nreused, explicitly retained, or released, and the turn ends only when the report\nto that user names, per Task, its outcome, the evidence behind it, and any\nunresolved blocker.\n\n**Safe failure:** preserve work and authority and report the state as unknown or\n`unverifiable`. Only positive proof of exit authorizes stop, abandon, or retry,\nand only an accepted settlement authorizes release. Every other observation,\nabsence included, is a checkpoint.\n\n## Classify the role\n\n| Current context | Role | Route |\n| ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------ |\n| The user explicitly asks to supervise, monitor, wait for results, track completion, coordinate a DAG, use a decision gate, or manage ask/reply | Coordinator | Use the supervised loop below |\n| The current prompt contains a live injected preamble with Task and Dispatch IDs | Dispatched worker | Follow the preamble and the worker obligations below |\n| The user asks to hand off ownership or start another agent/worktree without supervision | Handoff owner | Use `orca-cli`; create no Run, Task, or Dispatch and do not monitor completion |\n| A message carries a legacy authority label | Compatibility operator | Load the legacy contract reference before any lifecycle mutation |\n| No live preamble and no explicit supervision | Ordinary terminal agent | Do not emit lifecycle messages; use `orca-cli` for terminal/worktree work |\n\nModel or effort selection does not make a handoff supervised. Never substitute a\nnon-Orca subagent tool when Orca orchestration provenance was requested.\n\n## Authority and safety floor\n\n- A Run is a durable namespace and coordinator inbox; it does not schedule or\n place workers. A Task is work. A Dispatch is one authoritative Task attempt.\n- Lifecycle authority comes from the active Dispatch, not a terminal title,\n copied ID, old database row, provider transcript, or visible pane.\n- Workers use the exact executable, handle, capability, Task ID, and Dispatch ID\n in the live preamble. Never reconstruct, translate, or broaden those arguments.\n- After remote start, address the worker by Dispatch ID. The execution host owns\n process, filesystem, transcript, stop, and cleanup facts. Preserve the verdicts\n `live` / `unverifiable` / `exited`; contact loss is not process death.\n- Liveness is layered: `worker-list`'s `projection.liveness` is the fleet verdict\n for the agent; `worker-show`'s `observation.status` is PTY liveness only. A live\n terminal can still hold a dead or stuck agent.\n- Folder workspaces are valid; never require Git or assume a worktree.\n- Clients and remote servers update independently. Treat unknown optional fields\n as absent. A new stream operation requires advertised capability because old\n decoders may silently drop unknown opcodes. Never fall back to local execution\n when remote authority or capability is unproven.\n- Use the executable you used to run `skills get` for the entire run. In the\n examples below, replace `ORCA` with it; do not create a shell variable or run\n `ORCA` literally. If it fails, report that exact error instead of switching.\n- A successful `orchestration send` proves durable enqueue; its wake or nudge is\n best-effort attention only and does not prove the recipient read or accepted it.\n\n## Worker obligations\n\nThe injected preamble is authoritative. A dispatched worker must:\n\n1. Do only the current Task and use the preamble's `ask` command for a blocking\n coordinator question. Never open a local question TUI the coordinator cannot\n answer. Resume the same message ID after an ask timeout.\n2. Send heartbeats only at the cadence in the preamble. A heartbeat proves\n liveness, not completion.\n3. Read coordinator follow-ups at each natural checkpoint — before starting a\n new file, after a test run — and once more immediately before `worker_done`:\n `ORCA orchestration check --terminal --json`.\n4. Send `worker_done` exactly once, from the dispatched terminal, with a\n three-sentence executive summary, both lifecycle IDs, and explicit\n `--outcome succeeded` or `--outcome failed`. Never encode failure only in prose.\n5. Append `--files-modified` and `--report-path` only with real values when\n applicable. After `worker_done`, end the dispatched turn and idle; do not poll\n or start new work.\n\nA direct user instruction after completion starts new user-owned work and takes\nprecedence over the idle rule. Do not reuse the settled lifecycle IDs.\n\n## Canonical supervised loop\n\nConfirm the runtime, bind one Run, and start the full independent wave before\nwaiting. `worker-start --spec` creates the Task and its attempt in one call:\n\n```text\nORCA status --json\nORCA orchestration run-create --objective \"\" --json\nORCA orchestration worker-start --spec \"\" --worktree current --agent codex --json\nORCA orchestration worker-start --spec \"\" --worktree current --agent claude --json\nORCA orchestration check --wait --types \"worker_done,escalation,question\" --timeout-ms 900000 --json\n```\n\nIf `worker-start` exits non-zero, do not relaunch. Read the receipt's\n`failedStage` and `residualResources`, then load\n`references/recovery-and-cleanup.md`.\n\nUse `task-create` plus `worker-start --task ` for planned fan-out with\ndependencies or a retry of a known Task. Use dependencies only for real ordering\nand prefer parallel waves over chains deeper than three or four steps; nested\nworkers obey the depth limit, and a new Run does not reset the caller's depth.\n\nA consuming `check` names its caller with `--terminal `, never `--from`;\nomit it inside the coordinator's own Orca terminal. It returns the bound Run's\noldest FIFO Delivery and replays that batch until acknowledged. Process every\nmessage: reply to questions, validate each `worker_done` against the expected\nactive Dispatch, and decide each settled terminal's next owner before the ack:\n\n```text\nORCA orchestration reply --id --body \"\" --json\nORCA orchestration worker-release --dispatch --json\nORCA orchestration check --ack --wait --types \"worker_done,escalation,question\" --timeout-ms 900000 --json\n```\n\nKeep waiting until every expected Dispatch settles. A timeout or empty result is\na checkpoint, not a failure. Do not stop, retry, release, or launch a duplicate\neditor without the positive proof `## Outcome` requires.\n\nAfter three consecutive empty waits, stop waiting blindly and enumerate with\n`ORCA orchestration worker-list --include-remote --json` (defaults to the bound\nRun; `--run ` overrides; the receipt's `scope` names which), acting on\neach row's `projection.attention` categories, `projection.attention.requiresAction`, and literal `projection.nextAction` argv.\nRows come newest first and page at 100: while `page.hasMore`, follow `page.nextCursor` with `--cursor `.\nA `none` `nextAction` has no argv to run: read `liveness.reason` and keep waiting\nwith `check --wait`. Absence never earns an argv; settlement and pending work still do.\nLeave the wait only on positive proof the agent stopped: `exited` liveness, the\nworker's own observation of process exit, or a transcript whose final agent turn\nsent no `worker_done`. Then load `references/recovery-and-cleanup.md` and choose\n`worker-stop` or `worker-abandon` explicitly. `unverifiable` is absence,\nincluding when `worker-show` reports `agentWait` null. Absence never authorizes\nstop, abandon, retry, or release; keep waiting or inspect.\n\n`worker-start` is the normal path, composing placement, terminal readiness,\nprompt injection, and supervised resource ownership. `dispatch --inject` leaves\nan operator-created process unsupervised and is only for an expressiveness gap.\n\n## Task-spec contract\n\nEvery Task spec must be self-contained and name:\n\n- **Target:** the files, component, or environment in scope.\n- **Change:** the concrete result to produce.\n- **Constraints:** invariants, compatibility rules, and do-not-touch boundaries.\n- **Ownership:** what this worker may edit and any coordination boundary.\n- **Observable acceptance:** the test, output, or evidence that proves completion.\n\n## Completion accounting\n\nAfter an accepted success or failure report, immediately do exactly one:\n\n1. Reuse the same proven agent terminal for an immediate follow-up Dispatch.\n2. Record user-requested retention with `worker-retain`.\n3. Run `worker-release`.\n\nRelease is post-settlement cleanup, not cancellation. Only an accepted\nsettlement authorizes it; no other observation does. If release is uncertain,\nfollow its exact recovery receipt and never substitute `terminal close`.\n\nA valid `worker_done` settles the Task and Dispatch automatically; do not follow\nit with `task-update --status completed`. Enumerate the terminals still owing a\ndecision with `worker-list --run --terminal-state reclaimable --json`,\nand do not end the coordinator turn until it returns none.\n\n## Conditional references\n\nThis compact guide is sufficient for the normal local loop. At an action gate\nbelow, run `ORCA skills get orchestration --reference references/.md` and\nread only that document; `--references` lists the names. If the CLI rejects\n`--reference`, run `ORCA skills get orchestration --full` once instead: it\nreturns this exact kernel and every reference, so read only the named one. If an\nolder CLI rejects `--full`, keep this kernel's safety floor, use that command's\n`--help`, and never guess newer flags.\n\n| Action gate | Bundled reference |\n| ------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |\n| Expanded DAG waves, launch model/effort, same-terminal reuse, or review ownership | `references/coordinator-loop.md` |\n| You are a dispatched worker and the live preamble does not answer your question, or `check` returned an error | `references/worker-contract.md` |\n| New worktree, exact workspace, SSH, WSL, or connected-server placement | `references/placement-and-remote.md` |\n| Inbox replay, follow-up messages, group addresses, or decision gates | `references/messaging-and-gates.md` |\n| Failed/stopped/unknown attempts, retry, stop, abandon, retain, or uncertain release | `references/recovery-and-cleanup.md` |\n| Custom argv or terminal topology that `worker-start` cannot express | `references/low-level-topology.md` |\n| Any legacy label, adopted Run, compatibility receipt, or takeover | `references/legacy-contract-migration.md` |\n\nRetired scheduler commands are not aliases for Run creation. Recovery commands\nmust provide their exact next action; follow it with the same selected executable.\n\n---\n\n# Bundled references\n\nThese references belong to the version-matched guide above. Read only the documents named by its action gates.\n\n\n\n# Coordinator loop\n\nLoad this reference for expanded DAG waves, per-invocation launch preferences,\nsame-terminal reuse, or review ownership. The compact guide remains the source\nof truth for the loop order and completion boundary.\n\n## Ready waves\n\nCreate independent Tasks before the first wait. Encode only real dependencies,\nthen use the ready view as external memory:\n\n```text\nORCA orchestration task-create --spec \"\" --deps --json\nORCA orchestration task-list --ready --brief --json\n```\n\n`--brief` collapses whitespace and caps echoed specs at 160 characters;\n`spec_truncated` identifies shortened rows. Omit it when full specs are needed or\nwhen an older CLI rejects the flag. A nested worker must respect\n`nested_worker_depth_exceeded`; creating another Run does not reset depth.\n\n## Launch preferences\n\nFor a fresh Claude, Codex, Cursor, Antigravity, or Muse terminal, `--model`\naccepts an opaque provider model ID. Pass it only when the user named a model;\notherwise omit it so the worker inherits the user's configured agent default.\nAdd `--effort` only when that model supports it:\n\n```text\nORCA orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json\nORCA orchestration worker-start --task --worktree current --agent muse --model muse-spark-1.3 --json\n```\n\nOther agents, including `opencode`, reject `--model`; they run the model set in\ntheir own config, so a coordinator wanting a same-model opencode worker relies\non that config.\n\n`--effort` requires `--model`; neither option combines with `--terminal`. A\nconnected worker server must advertise launch-preference support before Orca\nforwards either field. Compare `launch.requested` with `launch.effective`; never\nclaim a model or effort from requested arguments alone.\n\n## Reuse after settlement\n\nChoose the terminal's next owner before acknowledging the Delivery. When the\nsame exact agent has immediate follow-up work, recover the proven handle and\ntransfer cleanup ownership to the new Dispatch:\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-start --task --terminal --json\n```\n\nOtherwise explicitly retain or release the settled worker. Do not leave it live\nonly to inspect output; archived output remains available through `worker-read`.\n\n## Review ownership\n\nA review-only `worker_done` authorizes synthesis of findings, not coordinator\nfile edits. Dispatch or hand off fixes unless the user explicitly assigned them\nto the coordinator. If the user's plan names a next owner, post-review fixes and\nPR preparation remain with that owner; the coordinator routes and synthesizes.\n\n\n\n# Legacy contract migration\n\nLoad this reference only for an authority label, adopted Run, compatibility or\nrecovery receipt, or explicit legacy takeover. A newly created attempt always\nuses the current grammar.\n\n## Authority labels\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported\n command printed with the message, using the same selected executable and\n arguments supplied by the original prompt.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded,\n at-least-once cutover replay. Process it idempotently and acknowledge only\n through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or\n lifecycle mutation.\n- An unlabeled current message uses the current guide and grammar.\n\nAn explicitly selected current Run, attested current binding, current Dispatch,\nor federated attachment takes precedence over legacy fallback. A retained\nadoption record alone does not grant mutation authority. If liveness, principal\nownership, capability, or the exact legacy contract is unproven, degrade to\nread-only inspection and never fall back to local execution.\n\nAdoption preserves the live agent process, PTY/session, terminal handle,\ntab/pane, worktree or folder workspace, Task, and Dispatch. It never restarts or\nreplaces the worker and never revives the retired scheduler. Loss of lifecycle\nauthority does not invalidate the existing process, assignment, or filesystem\nwork. Exact recovery may restore the same PTY once in its original inactive\nbackground tab; it must not spawn, write, signal, stop, switch, focus, split, or\ninject a terminal.\n\n## Compatibility recovery\n\nWhen a compatibility response returns structured next-step arguments, execute\nthose exact arguments with the same selected CLI executable. Do not translate\nfrom memory, broaden the recipient, or retry as a current mutation unless the\nreceipt explicitly authorizes it.\n\nA pending ask, reply, final Dispatch settlement, and consuming check have\ndurable recovery identities. Heartbeat and escalation remain at-least-once\nacross a manual contract-boundary retry. If an ask may already have been\nanswered, run the exact non-consuming recovery check printed by Orca before\ncreating any new question. Never guess among identical question threads.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The\ninitial command commits the question, prints its exact\n`ask --resume ` command, and exits with launcher status `75`. Run\nthat exact resume after the launcher or update boundary. For an attested WSL\nlaunch, preserve the printed `orca-ide` executable and distro route. Older WSL\nworkers without launch proof remain lifecycle read-only even while their\nterminal and filesystem work continue.\n\n## Read-only inspection and takeover\n\nRead-only inspection does not consume mail:\n\n```text\nORCA orchestration run-list --json\nORCA orchestration run-show --id run_legacy_local --json\nORCA orchestration run-show --id --json\nORCA orchestration task-list --run --json\nORCA orchestration inbox --full --json\nORCA orchestration check --terminal --peek --format --json\nORCA terminal read --terminal --json\nORCA terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\n`run_legacy_local` is an empty audit tombstone after adoption. Find the ordinary\nRun whose objective is `Recovered orchestration work from a contract update`.\n\nOnly when the original coordinator is unavailable or cannot prove retained\nauthority may a new live coordinator take over from its own terminal:\n\n```text\nORCA orchestration run-use --id --takeover-legacy --json\nORCA orchestration check --run --json\n```\n\nTakeover binds the authenticated invoking terminal; `--from` cannot nominate\nanother coordinator. It fences only the old coordinator and moves pending mail\ninto current Run delivery. It preserves live workers, Tasks, Dispatches, processes, and files.\nNever take over while the original coordinator is actively coordinating.\n\nDo not launch a replacement editor merely because Orca updated or authority is\nunclear. Keep the original worker as the only editor until a stable handoff\npoint, then use a fresh current Dispatch in a conflict-free placement.\n\n\n\n# Low-level topology\n\nLoad this reference only when `worker-start` cannot express required custom argv\nor terminal topology. It is not the normal supervised loop and is never a full\nhandoff recipe.\n\n```text\nORCA terminal create --worktree active --title --command \"\" --json\nORCA terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\nORCA orchestration dispatch --task --to --inject --json\n```\n\nWait for readiness only when startup could lose injected input. Prefer\nagent-first `worker-start` whenever its argv and topology are sufficient.\n\n`dispatch --inject` creates authoritative Task/Dispatch context but deliberately\nkeeps an operator-created process unsupervised: it creates no supervised worker\nresource row. `worker-show`, `worker-read`, and `worker-list` report the lane as\n`unsupervised`; `worker-stop` and `worker-abandon` do not close that process, and\nsettled retain/release take no process action.\n\nUse `worker-start --terminal ` when lifecycle ownership of an existing\nagent terminal is required. Never imply that low-level dispatch retroactively\nowns a process, never use it to route around the nested-depth limit, and never\nuse it for an ownership handoff.\n\n\n\n# Messaging and gates\n\nLoad this reference for inbox replay, attempt-specific guidance, group\naddresses, blocking questions, or coordinator-managed DAG decisions.\n\nA successful `send` proves durable enqueue. Wake and nudge are best-effort\nattention only: neither proves the recipient read the message, began a turn, or\naccepted steering.\n\n## Coordinator delivery loop\n\n`check` names its caller with `--terminal ` and is the only verb that\nrejects `--from`. Omit `--terminal` inside an Orca terminal, where Orca resolves\nthe caller; pass it explicitly from anywhere else, including a dispatched\nworker reading coordinator follow-ups.\n\nA consuming coordinator `check` returns the bound Run's oldest FIFO Delivery,\nup to 50 messages, and replays that exact batch until acknowledged. Process\nevery row and required terminal ownership decision before `--ack`. Type filters\ndecide when a waiter wakes; they do not authorize skipping older actionable\nmail. A Delivery therefore always carries the whole FIFO batch whatever its\ntypes, and a `check` without `--wait` hands that batch over unfiltered.\n`--peek` and `--all` are read-only inspection, not progress through the\ncoordinator inbox.\n\nAn empty wait or timeout is a checkpoint. Continue rolling waits until every\nexpected Dispatch settles. Heartbeat or visible activity means alive, not done.\n\n## Addresses\n\nUse a stable Dispatch address for attempt-specific coordinator guidance:\n\n```text\nORCA orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nDo not substitute a remote terminal handle. Omit `--from` for ordinary\ncoordinator calls; a dispatched worker instead copies the exact `--from` and\ncapability arguments in its preamble. `check` is the exception: it identifies\nits caller with `--terminal`, never `--from`.\n\nGroup addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`,\n`@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`. Every group but\n`@worktree:` means the live Dispatches of the sender's own Run. Mail goes\nto each `dispatch:` mailbox, except a worker coordinating a child Run\nreceives it in that `run:` mailbox. A sender bound to no Run is refused;\n`--run` must match the group audience and never grants membership.\nA Run group excludes its owning coordinator; a worker raising a blocker sends\nto `run:`. A worker that created its own Run addresses that Run's workers,\nnot its siblings. `@worktree:` reaches matching workspace terminals,\nincluding coordinators. Use groups only for intentional fan-out status or\nquestions. `worker_done`, heartbeat, and other\nDispatch lifecycle messages never target groups.\n\n## Questions and gates\n\nA worker uses `ask`; its timeout leaves one durable question pending, which the\nworker resumes by message ID. The coordinator answers that message with `reply`.\n\nUse a gate only for a coordinator-owned Task-DAG decision:\n\n```text\nORCA orchestration gate-create --task --question \"\" --options --json\nORCA orchestration gate-resolve --id --resolution \"\" --json\nORCA orchestration gate-list --task --json\n```\n\nPass `json_array` using the quoting rules of the active shell; do not copy POSIX\nsingle-quote syntax into PowerShell or `cmd.exe`.\n\nDo not create a gate merely to answer a worker's `ask`.\n\n\n\n# Placement and remote execution\n\nLoad this reference before creating a new worktree or placing work through SSH,\nWSL, or another connected Orca server.\n\n## Placement choices\n\nA fresh worker means a fresh agent terminal, not a new Git worktree. Use the\ncurrent or an exact existing workspace by default. Create a worktree only when\nthe user requested one or a concrete checkout or filesystem conflict makes\nsharing unsafe.\n\n```text\n# Current workspace; setup is not rerun.\nORCA orchestration worker-start --task --worktree current --agent codex --json\n\n# Stacked child worktree.\nORCA orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n\n# Independent top-level worktree.\nORCA orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nCurrent and exact existing workspaces create a fresh terminal unless\n`--terminal` is explicit. Folder workspaces are first-class; do not invoke Git\nor require worktree lineage when the selected workspace is a folder.\n\nRegister a folder workspace through project setup. `repo add --path `\nrequires a valid Git repository and rejects a plain directory:\n\n```text\nORCA project setup-existing-folder --project --host --path --kind folder --json\n```\n\nThen place work on the returned workspace with an exact selector. A worktree\nselector needs the full `::` value Orca returned, passed as\n`id:`; a bare repo id is not a worktree id. `new-child` and\n`new-top-level` are worktree creation and do not apply to a folder.\n\nNew worktrees use agent-first creation and run setup by default. Preserve the\nrepository's startup policy: `start-immediately` can report setup as `running`,\nwhile `wait-for-setup` gates prompt delivery on success. Orca lineage, Git base,\nfilesystem isolation, coordination parentage, UI grouping, and execution host\nare separate decisions.\n\n## Connected servers\n\nThe Run and Tasks remain authoritative on the current server. `--on` selects\nonly the worker's execution server and appears only on `worker-start`:\n\n```text\nORCA orchestration worker-start --task --on --worktree new-top-level --repo --name --agent codex --setup run --json\n```\n\nRemote `current` and `new-child` are invalid because they are ambiguous across\nservers. Use an exact discovered remote workspace, or `new-top-level` with an\nexact remote repository selector. After start, route every follow-up, read,\nstop, and cleanup by Dispatch ID; never repeat `--on` or substitute a remote\nterminal handle.\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-read --dispatch --limit 50 --json\nORCA orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\nORCA orchestration worker-list --run --include-remote --json\n```\n\n`worker-list` reads local fleet state only; enumerate remote workers with\n`--include-remote` or every one of them reads `unverifiable`. Scope every list\nwith `--run `: unscoped, it reports every Dispatch this runtime has\nrecorded, and the workers you are waiting on are lost in that history.\n\n## Execution-host and mixed-version floor\n\nThe execution host owns process, filesystem, transcript, stop, and cleanup\nfacts. Render only `live`, `unverifiable`, or `exited`. Connection loss, relay\nabsence, missing client inventory, or timeout yields `unverifiable`, never\nsynthetic exit and never a client-local substitute action.\n\nClients and servers update independently. Optional response fields may be\nabsent. Forward model/effort, transcript reads, cleanup, or another new remote\noperation only when the peer advertises the relevant capability; unknown stream\nopcodes can be silently dropped. A narrow unsupported response may degrade to a\ndocumented older path, but must not broaden the target or cross the execution\nboundary. Changing host-published content reaches old clients even without a\nwire-shape change, so preserve established semantics or negotiate the behavior.\n\nFor WSL, use the exact executable and arguments returned by Orca so the distro\nand packaged launcher remain bound. Do not translate a printed `orca-ide`\nrecovery command into a PATH-resolved local command.\n\n\n\n# Recovery and cleanup\n\nLoad this reference only after a failed/stopped/unknown attempt, explicit retry\ndecision, stop/abandon request, retention request, or uncertain release.\n\n| Proven state | Safe action |\n| ----------------------- | ------------------------------------------------------------------ |\n| `ready` or active | Keep waiting; optionally read bounded output |\n| `failed` or `stopped` | Start a replacement with `--retry-of`; repeat placement explicitly |\n| `outcome_unknown` | Inspect, then choose `worker-stop` or explicit `worker-abandon` |\n| Accepted `worker_done` | Reuse, retain, or release |\n| Remote contact lost | Preserve `unverifiable`; do not stop or retry from absence alone |\n| `unverifiable` liveness | Keep waiting or inspect; never stop, abandon, retry, or release |\n| Proven `exited` agent | Enumerate with `worker-list`; follow its `nextAction` |\n\n## Inspect before acting\n\n```text\nORCA orchestration worker-list --run --json\nORCA orchestration worker-list --run --include-remote --json\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-read --dispatch --limit 50 --json\n```\n\n`worker-list` is the enumerating command and the authority on agent liveness:\neach row carries `projection.liveness`, `projection.attention.categories`,\n`projection.attention.requiresAction`, and a literal `projection.nextAction`\nargv to run. Always scope it with `--run `; an unscoped list reports\nevery Dispatch this runtime has ever recorded and buries the live ones.\n`worker-show`'s `observation.status` is PTY liveness only, so a `live` terminal\nwhose agent died at a trust prompt still reads `live` there.\n\nWhen the two disagree, the fleet verdict decides — unless the fleet row is\n`unverifiable` for a reason that names a gap on this client rather than a fact\nabout the worker. `missing_status`, `host_unavailable`, and\n`capability_unsupported` are such gaps: the first means this runtime holds no\nstatus row, the second that it could not ask the execution host at all, and the\nthird that a stale peer answered but lacks the fleet-snapshot capability.\nAgainst any of them, a `worker-show` verdict sourced from the execution host is\nthe better evidence and outranks the row. Only `host_unavailable` is contact\nloss; the other two mean the host was never asked or answered without the\ncapability.\n\nThis never promotes absence. `unverifiable` from either command still authorizes\nnothing — only a positive `live` or `exited` verdict does.\n\nA worker started with `--on ` reads `unverifiable` until you\nenumerate with `--include-remote`, which asks its execution host for the\nverdict. Rows come newest first and past 100 the response pages, so follow\n`page.nextCursor` with `--cursor ` until `page.hasMore` is false.\n\n## Stall needs positive evidence\n\nLeave the wait only on positive proof the agent stopped: `exited` liveness, the\nworker's own observation of process exit, or a transcript whose final agent turn\nsent no `worker_done`. Only then choose `worker-stop` or `worker-abandon`.\n\n`unverifiable` is always absence — `missing_status`, `stale_status`,\n`restored_unconfirmed`, or a remote worker with no connection — and a null\n`agentWait` or an unchanged `worker-read` tail is that same absence seen again.\nAbsence never authorizes stop, abandon, retry, or release: keep waiting, or\ninspect until you hold one of the positive signals above. A `nextAction` that\nnames an inspecting command is asking for evidence, not for cleanup.\n\n`worker-read --source auto` uses a proven provider transcript when available and\notherwise returns bounded terminal output with a typed `fallbackReason`.\nContinue with its top-level cursor, which is pinned to that source. If Orca\nreports `source_changed`, restart without the old cursor. A bounded initial\ntranscript tail can return an EOF cursor that follows only newly appended records;\nread `contentComplete`, `clipping`, and `warnings` before assuming omitted older\nrecords are pageable. Never guess a provider session ID, transcript path, or\nremote terminal handle.\n\n## Was the mutation applied?\n\nWhen a mutation's response was lost and named no Dispatch, do not replay blind.\nEvery orchestration mutation accepts `--retry-request `, which reuses one\noperation identity so Orca can replay, join, or recover it instead of starting a\nduplicate. Ask what happened first:\n\n```text\nORCA orchestration request-show --request --json\n```\n\n`completed` means the mutation already took effect; read its recorded receipt\ninstead of rerunning. `pending` means the original mutation is still running or\nOrca restarted before recording its outcome; replay the original command with\n`--retry-request `. `absent` means this runtime holds no receipt\nunder your caller identity — that is not proof nothing happened, so inspect the\naffected Task, Dispatch, and terminal before deciding whether to retry.\n\nWhen a worker's terminal accepted input but the submit is unconfirmed, use\n`terminal send --wait-submit `: it observes the accepted prompt for that\nlong and, on timeout, returns the input-accepted receipt without resending.\n\n## Refused starts\n\n`dispatch` and `worker-start` refuse the following preflight cases with a stable\n`error.code`; read it before choosing a recovery, and treat `error.data.nextSteps`\nas the exact recovery text. Older hosts may omit `data`, so treat every field as\noptional.\n\n| Code | Meaning | Recovery |\n| -------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |\n| `task_not_found` | No Task with that id, or not in the bound Run (`data.taskId`, `data.runId`) | Check `task-list --json`; create the Task with `task-create` if it does not exist |\n| `task_not_startable` | Task cannot start now: not `ready`, or invalid `--retry-of` (`data.status`, `data.unmetDependencies`, `data.retryOf`) | Wait for running dependencies with `check --wait`; retry or unblock failed ones; inspect `dispatch-show` if already dispatched |\n| `inject_rejected` | `--inject` refused because no recognized agent runs in the target (`data.terminal`, `data.reason`) | Start a recognized agent there or pick another terminal; or dispatch without `--inject` and use `terminal send` |\n| `runtime_error` | Any other failure, including a target terminal that already owns an active Dispatch | Read the message, inspect state, and do not retry unchanged |\n\n## Retry, stop, and abandon\n\nRetry only a positively proven failed or stopped attempt. Name the failed Task\nwith `--task`, since `--spec` creates a new one. Placement is never silently\ninherited:\n\n```text\nORCA orchestration worker-start --task --retry-of --worktree --agent --json\n```\n\nAfter three consecutive failures for one Task, its dispatch context\ncircuit-breaks and the Task is failed. Do not route around that boundary with a\nnew Run or an unrelated Dispatch.\n\nFor `outcome_unknown`, inspect first, then make an explicit choice:\n\n```text\nORCA orchestration worker-stop --dispatch --json\nORCA orchestration worker-abandon --dispatch --json\n```\n\n`worker-stop` closes only the exact proven supervised agent terminal. It never\ndeletes the worktree, setup terminal, configured tabs, or unrelated processes.\n`worker-abandon` fences orchestration while accepting that resources may remain\nlive; it performs no remote, process, or filesystem action.\n\n## Retain and release\n\n```text\nORCA orchestration worker-retain --dispatch --json\nORCA orchestration worker-release --dispatch --json\n```\n\nRetain only when the user explicitly wants the settled terminal kept live.\nRelease works after succeeded and failed reports, archives readable output, and\ncloses only the exact terminal owned by that settled Dispatch. Replays may call\nrelease again safely. Reused, pre-existing, setup, coordinator, active,\nuser-taken-over, and unproven terminals are retained.\n\nA `worker-start` that failed before its agent was ready still owns the terminal\nit created. Its receipt names `worker-release`, and `worker-list` reports that\nrow as `reclaimable`; release it there rather than closing the terminal by hand.\n\nNever release because of timeout, TUI idle, heartbeat, status, question,\nescalation, or stale/rejected completion. If the receipt says `release_pending`\nor `release_unknown`, follow its exact recovery action. Never substitute\n`terminal close`.\n\n`orchestration reset` is destructive recovery. Do not run it during active\ncoordination unless the user explicitly abandons that state.\n\n\n\n# Worker contract\n\nThe injected preamble is authoritative. Copy its command rather than\nreconstructing flags. In particular, preserve the exact executable, worker\nhandle, Dispatch capability, Task ID, and Dispatch ID.\n\n## Heartbeat\n\nSend heartbeats only at the cadence required by the live preamble. Skip them\nwhile blocked inside `ask` or `check --wait`; those calls are liveness signals.\n\n```text\nORCA orchestration send --from --dispatch-capability --type heartbeat --subject \"alive\" --task-id --dispatch-id --phase \"\"\n```\n\nUse typed lifecycle flags, not a hand-written JSON payload. A heartbeat proves\nliveness, never completion.\n\n## Ask and resume\n\nUse Orca `ask` whenever the coordinator must answer. Never open a local question\nTUI the coordinator cannot answer.\n\n```text\nORCA orchestration ask --from --dispatch-capability --question \"\" --options \",\" --timeout-ms 600000\n\nORCA orchestration ask --from --dispatch-capability --resume --timeout-ms 600000\n```\n\nA timeout or disconnect leaves the original question pending. Resume its\nmessage ID; do not create a duplicate question.\n\n## Reading coordinator follow-ups\n\nThe coordinator steers a running worker with `send --to dispatch:`. That\nenqueue is durable but does not interrupt you, so nothing arrives unless you\nlook:\n\n```text\nORCA orchestration check --terminal --json\n```\n\nRun it at each natural checkpoint — before starting a new file, after a test\nrun — and once more immediately before `worker_done`, so a redirect or a\ncancellation lands before the Task settles. `check` names its caller with\n`--terminal`, never `--from`. Stop checking after `worker_done`.\n\nIf `check` returns `consumer_fenced`, this process no longer owns its Dispatch:\nthe Attempt was re-attached to another worker or settled without you. Stop, do\nnot send `worker_done`, and do not retry the check. An empty `check` never means\nyou were replaced; `consumer_fenced` is the only way you learn that.\n\n## Escalation\n\nEscalate only before completion and only when the coordinator must intervene:\n\n```text\nORCA orchestration send --from --dispatch-capability --type escalation --subject \"Blocked: \" --body \"
\" --task-id --dispatch-id \n```\n\n## Completion\n\nSend exactly one terminal report. `--body` is three sentences: what changed,\nwhat was found, and what remains. Use `--outcome failed` when the requested work\nis not complete; never hide failure in prose or silently exit.\n\nAppend `--files-modified` or `--report-path` only when applicable, using actual\npaths. Do not send documentation placeholders as metadata.\n\n```text\nORCA orchestration send --from --dispatch-capability --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded\n```\n\nAfter `worker_done`, end the dispatched turn and idle. Do not poll, close your\nown terminal, or begin unrelated work. A later direct user instruction is new\nuser-owned work and must not reuse settled lifecycle IDs; a supervised follow-up\narrives with a fresh preamble and Task block.\n" +const ORCHESTRATION_FULL_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Coordinate supervised Orca workers: threaded messages, blocking ask/reply,\n task dispatch, worker_done/escalation waits, task DAGs, decision gates,\n coordinator loops, and decomposing work across agents. Use `orca-cli` for full\n ownership handoffs — \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", \"another worktree\" — unless asked to supervise, monitor, or coordinate\n a DAG, and for terminal control, lightweight terminal prompts, shell commands,\n Orca worktree management, and reading or waiting on terminals.\n---\n\n# Orca orchestration\n\nOrchestration is Orca's structured coordination layer. It records who owns work,\nwhich attempt is authoritative, and when supervised work has settled.\n\n## Outcome\n\n**Result:** every in-scope Task has one explicit outcome and every settled worker\nterminal has a next owner or cleanup decision. **Next consumer:** the user who\nrequested supervision. **Done:** all expected Dispatches have settled, every\ndelivered message was processed before acknowledgment, each settled worker was\nreused, explicitly retained, or released, and the turn ends only when the report\nto that user names, per Task, its outcome, the evidence behind it, and any\nunresolved blocker.\n\n**Safe failure:** preserve work and authority and report the state as unknown or\n`unverifiable`. Only positive proof of exit authorizes stop, abandon, or retry,\nand only an accepted settlement authorizes release. Every other observation,\nabsence included, is a checkpoint.\n\n## Classify the role\n\n| Current context | Role | Route |\n| ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------ |\n| The user explicitly asks to supervise, monitor, wait for results, track completion, coordinate a DAG, use a decision gate, or manage ask/reply | Coordinator | Use the supervised loop below |\n| The current prompt contains a live injected preamble with Task and Dispatch IDs | Dispatched worker | Follow the preamble and the worker obligations below |\n| The user asks to hand off ownership or start another agent/worktree without supervision | Handoff owner | Use `orca-cli`; create no Run, Task, or Dispatch and do not monitor completion |\n| A message carries a legacy authority label | Compatibility operator | Load the legacy contract reference before any lifecycle mutation |\n| No live preamble and no explicit supervision | Ordinary terminal agent | Do not emit lifecycle messages; use `orca-cli` for terminal/worktree work |\n\nModel or effort selection does not make a handoff supervised. Never substitute a\nnon-Orca subagent tool when Orca orchestration provenance was requested.\n\n## Authority and safety floor\n\n- A Run is a durable namespace and coordinator inbox; it does not schedule or\n place workers. A Task is work. A Dispatch is one authoritative Task attempt.\n- Lifecycle authority comes from the active Dispatch, not a terminal title,\n copied ID, old database row, provider transcript, or visible pane.\n- Workers use the exact executable, handle, Task ID, and Dispatch ID in the live\n preamble, plus any other flag it carries. Never reconstruct, translate, or\n broaden those arguments.\n- After remote start, address the worker by Dispatch ID. The execution host owns\n process, filesystem, transcript, stop, and cleanup facts. Preserve the verdicts\n `live` / `unverifiable` / `exited`; contact loss is not process death.\n- Liveness is layered: `worker-list`'s `projection.liveness` is the fleet verdict\n for the agent; `worker-show`'s `observation.status` is PTY liveness only. A live\n terminal can still hold a dead or stuck agent.\n- Folder workspaces are valid; never require Git or assume a worktree.\n- Clients and remote servers update independently. Treat unknown optional fields\n as absent. A new stream operation requires advertised capability because old\n decoders may silently drop unknown opcodes. Never fall back to local execution\n when remote authority or capability is unproven.\n- Use the executable you used to run `skills get` for the entire run. In the\n examples below, replace `ORCA` with it; do not create a shell variable or run\n `ORCA` literally. If it fails, report that exact error instead of switching.\n- A successful `orchestration send` proves durable enqueue; its wake or nudge is\n best-effort attention only and does not prove the recipient read or accepted it.\n\n## Worker obligations\n\nThe injected preamble is authoritative. A dispatched worker must:\n\n1. Do only the current Task and use the preamble's `ask` command for a blocking\n coordinator question. Never open a local question TUI the coordinator cannot\n answer. Resume the same message ID after an ask timeout.\n2. Send heartbeats only at the cadence in the preamble. A heartbeat proves\n liveness, not completion.\n3. Read coordinator follow-ups at each natural checkpoint — before starting a\n new file, after a test run — and once more immediately before `worker_done`:\n `ORCA orchestration check --terminal --json`.\n4. Send `worker_done` exactly once, from the dispatched terminal, with a\n three-sentence executive summary, both lifecycle IDs, and explicit\n `--outcome succeeded` or `--outcome failed`. Never encode failure only in prose.\n5. Append `--files-modified` and `--report-path` only with real values when\n applicable. After `worker_done`, end the dispatched turn and idle; do not poll\n or start new work.\n\nA direct user instruction after completion starts new user-owned work and takes\nprecedence over the idle rule. Do not reuse the settled lifecycle IDs.\n\n## Canonical supervised loop\n\nConfirm the runtime, bind one Run, and start the full independent wave before\nwaiting. `worker-start --spec` creates the Task and its attempt in one call:\n\n```text\nORCA status --json\nORCA orchestration run-create --objective \"\" --json\nORCA orchestration worker-start --spec \"\" --worktree current --agent codex --json\nORCA orchestration worker-start --spec \"\" --worktree current --agent claude --json\nORCA orchestration check --wait --types \"worker_done,escalation,question\" --timeout-ms 900000 --json\n```\n\nIf `worker-start` exits non-zero, do not relaunch. Read the receipt's\n`failedStage` and `residualResources`, then load\n`references/recovery-and-cleanup.md`.\n\nUse `task-create` plus `worker-start --task ` for planned fan-out with\ndependencies or a retry of a known Task. Use dependencies only for real ordering\nand prefer parallel waves over chains deeper than three or four steps; nested\nworkers obey the depth limit, and a new Run does not reset the caller's depth.\n\nA consuming `check` names its caller with `--terminal `, never `--from`;\nomit it inside the coordinator's own Orca terminal. It returns the bound Run's\noldest FIFO Delivery and replays that batch until acknowledged. Process every\nmessage: reply to questions, validate each `worker_done` against the expected\nactive Dispatch, and decide each settled terminal's next owner before the ack:\n\n```text\nORCA orchestration reply --id --body \"\" --json\nORCA orchestration worker-release --dispatch --json\nORCA orchestration check --ack --wait --types \"worker_done,escalation,question\" --timeout-ms 900000 --json\n```\n\nKeep waiting until every expected Dispatch settles. A timeout or empty result is\na checkpoint, not a failure. Do not stop, retry, release, or launch a duplicate\neditor without the positive proof `## Outcome` requires.\n\nAfter three consecutive empty waits, stop waiting blindly and enumerate with\n`ORCA orchestration worker-list --include-remote --json` (defaults to the bound\nRun; `--run ` overrides; the receipt's `scope` names which), acting on\neach row's `projection.attention` categories, `projection.attention.requiresAction`, and literal `projection.nextAction` argv.\nRows come newest first and page at 100: while `page.hasMore`, follow `page.nextCursor` with `--cursor `.\nA `none` `nextAction` has no argv to run: read `liveness.reason` and keep waiting\nwith `check --wait`. Absence never earns an argv; settlement and pending work still do.\nLeave the wait only on positive proof the agent stopped: `exited` liveness, the\nworker's own observation of process exit, or a transcript whose final agent turn\nsent no `worker_done`. Then load `references/recovery-and-cleanup.md` and choose\n`worker-stop` or `worker-abandon` explicitly. `unverifiable` is absence,\nincluding when `worker-show` reports `agentWait` null. Absence never authorizes\nstop, abandon, retry, or release; keep waiting or inspect.\n\n`worker-start` is the normal path, composing placement, terminal readiness,\nprompt injection, and supervised resource ownership. `dispatch --inject` leaves\nan operator-created process unsupervised and is only for an expressiveness gap.\n\n## Task-spec contract\n\nEvery Task spec must be self-contained and name:\n\n- **Target:** the files, component, or environment in scope.\n- **Change:** the concrete result to produce.\n- **Constraints:** invariants, compatibility rules, and do-not-touch boundaries.\n- **Ownership:** what this worker may edit and any coordination boundary.\n- **Observable acceptance:** the test, output, or evidence that proves completion.\n\n## Completion accounting\n\nAfter an accepted success or failure report, immediately do exactly one:\n\n1. Reuse the same proven agent terminal for an immediate follow-up Dispatch.\n2. Record user-requested retention with `worker-retain`.\n3. Run `worker-release`.\n\nRelease is post-settlement cleanup, not cancellation. Only an accepted\nsettlement authorizes it; no other observation does. If release is uncertain,\nfollow its exact recovery receipt and never substitute `terminal close`.\n\nA valid `worker_done` settles the Task and Dispatch automatically; do not follow\nit with `task-update --status completed`. Enumerate the terminals still owing a\ndecision with `worker-list --run --terminal-state reclaimable --json`,\nand do not end the coordinator turn until it returns none.\n\n## Conditional references\n\nThis compact guide is sufficient for the normal local loop. At an action gate\nbelow, run `ORCA skills get orchestration --reference references/.md` and\nread only that document; `--references` lists the names. If the CLI rejects\n`--reference`, run `ORCA skills get orchestration --full` once instead: it\nreturns this exact kernel and every reference, so read only the named one. If an\nolder CLI rejects `--full`, keep this kernel's safety floor, use that command's\n`--help`, and never guess newer flags.\n\n| Action gate | Bundled reference |\n| ------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |\n| Expanded DAG waves, launch model/effort, same-terminal reuse, or review ownership | `references/coordinator-loop.md` |\n| You are a dispatched worker and the live preamble does not answer your question, or `check` returned an error | `references/worker-contract.md` |\n| New worktree, exact workspace, SSH, WSL, or connected-server placement | `references/placement-and-remote.md` |\n| Inbox replay, follow-up messages, group addresses, or decision gates | `references/messaging-and-gates.md` |\n| Failed/stopped/unknown attempts, retry, stop, abandon, retain, or uncertain release | `references/recovery-and-cleanup.md` |\n| Custom argv or terminal topology that `worker-start` cannot express | `references/low-level-topology.md` |\n| Any legacy label, adopted Run, compatibility receipt, or takeover | `references/legacy-contract-migration.md` |\n\nRetired scheduler commands are not aliases for Run creation. Recovery commands\nmust provide their exact next action; follow it with the same selected executable.\n\n---\n\n# Bundled references\n\nThese references belong to the version-matched guide above. Read only the documents named by its action gates.\n\n\n\n# Coordinator loop\n\nLoad this reference for expanded DAG waves, per-invocation launch preferences,\nsame-terminal reuse, or review ownership. The compact guide remains the source\nof truth for the loop order and completion boundary.\n\n## Ready waves\n\nCreate independent Tasks before the first wait. Encode only real dependencies,\nthen use the ready view as external memory:\n\n```text\nORCA orchestration task-create --spec \"\" --deps --json\nORCA orchestration task-list --ready --brief --json\n```\n\n`--brief` collapses whitespace and caps echoed specs at 160 characters;\n`spec_truncated` identifies shortened rows. Omit it when full specs are needed or\nwhen an older CLI rejects the flag. A nested worker must respect\n`nested_worker_depth_exceeded`; creating another Run does not reset depth.\n\n## Launch preferences\n\nFor a fresh Claude, Codex, Cursor, Antigravity, or Muse terminal, `--model`\naccepts an opaque provider model ID. Pass it only when the user named a model;\notherwise omit it so the worker inherits the user's configured agent default.\nAdd `--effort` only when that model supports it:\n\n```text\nORCA orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json\nORCA orchestration worker-start --task --worktree current --agent muse --model muse-spark-1.3 --json\n```\n\nOther agents, including `opencode`, reject `--model`; they run the model set in\ntheir own config, so a coordinator wanting a same-model opencode worker relies\non that config.\n\n`--effort` requires `--model`; neither option combines with `--terminal`. A\nconnected worker server must advertise launch-preference support before Orca\nforwards either field. Compare `launch.requested` with `launch.effective`; never\nclaim a model or effort from requested arguments alone.\n\n## Reuse after settlement\n\nChoose the terminal's next owner before acknowledging the Delivery. When the\nsame exact agent has immediate follow-up work, recover the proven handle and\ntransfer cleanup ownership to the new Dispatch:\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-start --task --terminal --json\n```\n\nOtherwise explicitly retain or release the settled worker. Do not leave it live\nonly to inspect output; archived output remains available through `worker-read`.\n\n## Review ownership\n\nA review-only `worker_done` authorizes synthesis of findings, not coordinator\nfile edits. Dispatch or hand off fixes unless the user explicitly assigned them\nto the coordinator. If the user's plan names a next owner, post-review fixes and\nPR preparation remain with that owner; the coordinator routes and synthesizes.\n\n\n\n# Legacy contract migration\n\nLoad this reference only for an authority label, adopted Run, compatibility or\nrecovery receipt, or explicit legacy takeover. A newly created attempt always\nuses the current grammar.\n\n## Authority labels\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported\n command printed with the message, using the same selected executable and\n arguments supplied by the original prompt.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded,\n at-least-once cutover replay. Process it idempotently and acknowledge only\n through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or\n lifecycle mutation.\n- An unlabeled current message uses the current guide and grammar.\n\nAn explicitly selected current Run, attested current binding, current Dispatch,\nor federated attachment takes precedence over legacy fallback. A retained\nadoption record alone does not grant mutation authority. If liveness, principal\nownership, capability, or the exact legacy contract is unproven, degrade to\nread-only inspection and never fall back to local execution.\n\nAdoption preserves the live agent process, PTY/session, terminal handle,\ntab/pane, worktree or folder workspace, Task, and Dispatch. It never restarts or\nreplaces the worker and never revives the retired scheduler. Loss of lifecycle\nauthority does not invalidate the existing process, assignment, or filesystem\nwork. Exact recovery may restore the same PTY once in its original inactive\nbackground tab; it must not spawn, write, signal, stop, switch, focus, split, or\ninject a terminal.\n\n## Compatibility recovery\n\nWhen a compatibility response returns structured next-step arguments, execute\nthose exact arguments with the same selected CLI executable. Do not translate\nfrom memory, broaden the recipient, or retry as a current mutation unless the\nreceipt explicitly authorizes it.\n\nA pending ask, reply, final Dispatch settlement, and consuming check have\ndurable recovery identities. Heartbeat and escalation remain at-least-once\nacross a manual contract-boundary retry. If an ask may already have been\nanswered, run the exact non-consuming recovery check printed by Orca before\ncreating any new question. Never guess among identical question threads.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The\ninitial command commits the question, prints its exact\n`ask --resume ` command, and exits with launcher status `75`. Run\nthat exact resume after the launcher or update boundary. For an attested WSL\nlaunch, preserve the printed `orca-ide` executable and distro route. Older WSL\nworkers without launch proof remain lifecycle read-only even while their\nterminal and filesystem work continue.\n\n## Read-only inspection and takeover\n\nRead-only inspection does not consume mail:\n\n```text\nORCA orchestration run-list --json\nORCA orchestration run-show --id run_legacy_local --json\nORCA orchestration run-show --id --json\nORCA orchestration task-list --run --json\nORCA orchestration inbox --full --json\nORCA orchestration check --terminal --peek --format --json\nORCA terminal read --terminal --json\nORCA terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\n`run_legacy_local` is an empty audit tombstone after adoption. Find the ordinary\nRun whose objective is `Recovered orchestration work from a contract update`.\n\nOnly when the original coordinator is unavailable or cannot prove retained\nauthority may a new live coordinator take over from its own terminal:\n\n```text\nORCA orchestration run-use --id --takeover-legacy --json\nORCA orchestration check --run --json\n```\n\nTakeover binds the authenticated invoking terminal; `--from` cannot nominate\nanother coordinator. It fences only the old coordinator and moves pending mail\ninto current Run delivery. It preserves live workers, Tasks, Dispatches, processes, and files.\nNever take over while the original coordinator is actively coordinating.\n\nDo not launch a replacement editor merely because Orca updated or authority is\nunclear. Keep the original worker as the only editor until a stable handoff\npoint, then use a fresh current Dispatch in a conflict-free placement.\n\n\n\n# Low-level topology\n\nLoad this reference only when `worker-start` cannot express required custom argv\nor terminal topology. It is not the normal supervised loop and is never a full\nhandoff recipe.\n\n```text\nORCA terminal create --worktree active --title --command \"\" --json\nORCA terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\nORCA orchestration dispatch --task --to --inject --json\n```\n\nWait for readiness only when startup could lose injected input. Prefer\nagent-first `worker-start` whenever its argv and topology are sufficient.\n\n`dispatch --inject` creates authoritative Task/Dispatch context but deliberately\nkeeps an operator-created process unsupervised: it creates no supervised worker\nresource row. `worker-show`, `worker-read`, and `worker-list` report the lane as\n`unsupervised`; `worker-stop` and `worker-abandon` do not close that process, and\nsettled retain/release take no process action.\n\nUse `worker-start --terminal ` when lifecycle ownership of an existing\nagent terminal is required. Never imply that low-level dispatch retroactively\nowns a process, never use it to route around the nested-depth limit, and never\nuse it for an ownership handoff.\n\n\n\n# Messaging and gates\n\nLoad this reference for inbox replay, attempt-specific guidance, group\naddresses, blocking questions, or coordinator-managed DAG decisions.\n\nA successful `send` proves durable enqueue. Wake and nudge are best-effort\nattention only: neither proves the recipient read the message, began a turn, or\naccepted steering.\n\n## Coordinator delivery loop\n\n`check` names its caller with `--terminal ` and is the only verb that\nrejects `--from`. Omit `--terminal` inside an Orca terminal, where Orca resolves\nthe caller; pass it explicitly from anywhere else, including a dispatched\nworker reading coordinator follow-ups.\n\nA consuming coordinator `check` returns the bound Run's oldest FIFO Delivery,\nup to 50 messages, and replays that exact batch until acknowledged. Process\nevery row and required terminal ownership decision before `--ack`. Type filters\ndecide when a waiter wakes; they do not authorize skipping older actionable\nmail. A Delivery therefore always carries the whole FIFO batch whatever its\ntypes, and a `check` without `--wait` hands that batch over unfiltered.\n`--peek` and `--all` are read-only inspection, not progress through the\ncoordinator inbox.\n\nAn empty wait or timeout is a checkpoint. Continue rolling waits until every\nexpected Dispatch settles. Heartbeat or visible activity means alive, not done.\n\n## Addresses\n\nUse a stable Dispatch address for attempt-specific coordinator guidance:\n\n```text\nORCA orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nDo not substitute a remote terminal handle. Omit `--from` for ordinary\ncoordinator calls; a dispatched worker instead copies the exact `--from` and\nother arguments in its preamble. `check` is the exception: it identifies\nits caller with `--terminal`, never `--from`.\n\nGroup addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`,\n`@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`. Every group but\n`@worktree:` means the live Dispatches of the sender's own Run. Mail goes\nto each `dispatch:` mailbox, except a worker coordinating a child Run\nreceives it in that `run:` mailbox. A sender bound to no Run is refused;\n`--run` must match the group audience and never grants membership.\nA Run group excludes its owning coordinator; a worker raising a blocker sends\nto `run:`. A worker that created its own Run addresses that Run's workers,\nnot its siblings. `@worktree:` reaches matching workspace terminals,\nincluding coordinators. Use groups only for intentional fan-out status or\nquestions. `worker_done`, heartbeat, and other\nDispatch lifecycle messages never target groups.\n\n## Questions and gates\n\nA worker uses `ask`; its timeout leaves one durable question pending, which the\nworker resumes by message ID. The coordinator answers that message with `reply`.\n\nUse a gate only for a coordinator-owned Task-DAG decision:\n\n```text\nORCA orchestration gate-create --task --question \"\" --options --json\nORCA orchestration gate-resolve --id --resolution \"\" --json\nORCA orchestration gate-list --task --json\n```\n\nPass `json_array` using the quoting rules of the active shell; do not copy POSIX\nsingle-quote syntax into PowerShell or `cmd.exe`.\n\nDo not create a gate merely to answer a worker's `ask`.\n\n\n\n# Placement and remote execution\n\nLoad this reference before creating a new worktree or placing work through SSH,\nWSL, or another connected Orca server.\n\n## Placement choices\n\nA fresh worker means a fresh agent terminal, not a new Git worktree. Use the\ncurrent or an exact existing workspace by default. Create a worktree only when\nthe user requested one or a concrete checkout or filesystem conflict makes\nsharing unsafe.\n\n```text\n# Current workspace; setup is not rerun.\nORCA orchestration worker-start --task --worktree current --agent codex --json\n\n# Stacked child worktree.\nORCA orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n\n# Independent top-level worktree.\nORCA orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nCurrent and exact existing workspaces create a fresh terminal unless\n`--terminal` is explicit. Folder workspaces are first-class; do not invoke Git\nor require worktree lineage when the selected workspace is a folder.\n\nRegister a folder workspace through project setup. `repo add --path `\nrequires a valid Git repository and rejects a plain directory:\n\n```text\nORCA project setup-existing-folder --project --host --path --kind folder --json\n```\n\nThen place work on the returned workspace with an exact selector. A worktree\nselector needs the full `::` value Orca returned, passed as\n`id:`; a bare repo id is not a worktree id. `new-child` and\n`new-top-level` are worktree creation and do not apply to a folder.\n\nNew worktrees use agent-first creation and run setup by default. Preserve the\nrepository's startup policy: `start-immediately` can report setup as `running`,\nwhile `wait-for-setup` gates prompt delivery on success. Orca lineage, Git base,\nfilesystem isolation, coordination parentage, UI grouping, and execution host\nare separate decisions.\n\n## Connected servers\n\nThe Run and Tasks remain authoritative on the current server. `--on` selects\nonly the worker's execution server and appears only on `worker-start`:\n\n```text\nORCA orchestration worker-start --task --on --worktree new-top-level --repo --name --agent codex --setup run --json\n```\n\nRemote `current` and `new-child` are invalid because they are ambiguous across\nservers. Use an exact discovered remote workspace, or `new-top-level` with an\nexact remote repository selector. After start, route every follow-up, read,\nstop, and cleanup by Dispatch ID; never repeat `--on` or substitute a remote\nterminal handle.\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-read --dispatch --limit 50 --json\nORCA orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\nORCA orchestration worker-list --run --include-remote --json\n```\n\n`worker-list` reads local fleet state only; enumerate remote workers with\n`--include-remote` or every one of them reads `unverifiable`. Scope every list\nwith `--run `: unscoped, it reports every Dispatch this runtime has\nrecorded, and the workers you are waiting on are lost in that history.\n\n## Execution-host and mixed-version floor\n\nThe execution host owns process, filesystem, transcript, stop, and cleanup\nfacts. Render only `live`, `unverifiable`, or `exited`. Connection loss, relay\nabsence, missing client inventory, or timeout yields `unverifiable`, never\nsynthetic exit and never a client-local substitute action.\n\nClients and servers update independently. Optional response fields may be\nabsent. Forward model/effort, transcript reads, cleanup, or another new remote\noperation only when the peer advertises the relevant capability; unknown stream\nopcodes can be silently dropped. A narrow unsupported response may degrade to a\ndocumented older path, but must not broaden the target or cross the execution\nboundary. Changing host-published content reaches old clients even without a\nwire-shape change, so preserve established semantics or negotiate the behavior.\n\nFor WSL, use the exact executable and arguments returned by Orca so the distro\nand packaged launcher remain bound. Do not translate a printed `orca-ide`\nrecovery command into a PATH-resolved local command.\n\n\n\n# Recovery and cleanup\n\nLoad this reference only after a failed/stopped/unknown attempt, explicit retry\ndecision, stop/abandon request, retention request, or uncertain release.\n\n| Proven state | Safe action |\n| ----------------------- | ------------------------------------------------------------------ |\n| `ready` or active | Keep waiting; optionally read bounded output |\n| `failed` or `stopped` | Start a replacement with `--retry-of`; repeat placement explicitly |\n| `outcome_unknown` | Inspect, then choose `worker-stop` or explicit `worker-abandon` |\n| Accepted `worker_done` | Reuse, retain, or release |\n| Remote contact lost | Preserve `unverifiable`; do not stop or retry from absence alone |\n| `unverifiable` liveness | Keep waiting or inspect; never stop, abandon, retry, or release |\n| Proven `exited` agent | Enumerate with `worker-list`; follow its `nextAction` |\n\n## Inspect before acting\n\n```text\nORCA orchestration worker-list --run --json\nORCA orchestration worker-list --run --include-remote --json\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-read --dispatch --limit 50 --json\n```\n\n`worker-list` is the enumerating command and the authority on agent liveness:\neach row carries `projection.liveness`, `projection.attention.categories`,\n`projection.attention.requiresAction`, and a literal `projection.nextAction`\nargv to run. Always scope it with `--run `; an unscoped list reports\nevery Dispatch this runtime has ever recorded and buries the live ones.\n`worker-show`'s `observation.status` is PTY liveness only, so a `live` terminal\nwhose agent died at a trust prompt still reads `live` there.\n\nWhen the two disagree, the fleet verdict decides — unless the fleet row is\n`unverifiable` for a reason that names a gap on this client rather than a fact\nabout the worker. `missing_status`, `host_unavailable`, and\n`capability_unsupported` are such gaps: the first means this runtime holds no\nstatus row, the second that it could not ask the execution host at all, and the\nthird that a stale peer answered but lacks the fleet-snapshot capability.\nAgainst any of them, a `worker-show` verdict sourced from the execution host is\nthe better evidence and outranks the row. Only `host_unavailable` is contact\nloss; the other two mean the host was never asked or answered without the\ncapability.\n\nThis never promotes absence. `unverifiable` from either command still authorizes\nnothing — only a positive `live` or `exited` verdict does.\n\nA worker started with `--on ` reads `unverifiable` until you\nenumerate with `--include-remote`, which asks its execution host for the\nverdict. Rows come newest first and past 100 the response pages, so follow\n`page.nextCursor` with `--cursor ` until `page.hasMore` is false.\n\n## Stall needs positive evidence\n\nLeave the wait only on positive proof the agent stopped: `exited` liveness, the\nworker's own observation of process exit, or a transcript whose final agent turn\nsent no `worker_done`. Only then choose `worker-stop` or `worker-abandon`.\n\n`unverifiable` is always absence — `missing_status`, `stale_status`,\n`restored_unconfirmed`, or a remote worker with no connection — and a null\n`agentWait` or an unchanged `worker-read` tail is that same absence seen again.\nAbsence never authorizes stop, abandon, retry, or release: keep waiting, or\ninspect until you hold one of the positive signals above. A `nextAction` that\nnames an inspecting command is asking for evidence, not for cleanup.\n\n`worker-read --source auto` uses a proven provider transcript when available and\notherwise returns bounded terminal output with a typed `fallbackReason`.\nContinue with its top-level cursor, which is pinned to that source. If Orca\nreports `source_changed`, restart without the old cursor. A bounded initial\ntranscript tail can return an EOF cursor that follows only newly appended records;\nread `contentComplete`, `clipping`, and `warnings` before assuming omitted older\nrecords are pageable. Never guess a provider session ID, transcript path, or\nremote terminal handle.\n\n## Was the mutation applied?\n\nWhen a mutation's response was lost and named no Dispatch, do not replay blind.\nEvery orchestration mutation accepts `--retry-request `, which reuses one\noperation identity so Orca can replay, join, or recover it instead of starting a\nduplicate. Ask what happened first:\n\n```text\nORCA orchestration request-show --request --json\n```\n\n`completed` means the mutation already took effect; read its recorded receipt\ninstead of rerunning. `pending` means the original mutation is still running or\nOrca restarted before recording its outcome; replay the original command with\n`--retry-request `. `absent` means this runtime holds no receipt\nunder your caller identity — that is not proof nothing happened, so inspect the\naffected Task, Dispatch, and terminal before deciding whether to retry.\n\nWhen a worker's terminal accepted input but the submit is unconfirmed, use\n`terminal send --wait-submit `: it observes the accepted prompt for that\nlong and, on timeout, returns the input-accepted receipt without resending.\n\n## Refused starts\n\n`dispatch` and `worker-start` refuse the following preflight cases with a stable\n`error.code`; read it before choosing a recovery, and treat `error.data.nextSteps`\nas the exact recovery text. Older hosts may omit `data`, so treat every field as\noptional.\n\n| Code | Meaning | Recovery |\n| -------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |\n| `task_not_found` | No Task with that id, or not in the bound Run (`data.taskId`, `data.runId`) | Check `task-list --json`; create the Task with `task-create` if it does not exist |\n| `task_not_startable` | Task cannot start now: not `ready`, or invalid `--retry-of` (`data.status`, `data.unmetDependencies`, `data.retryOf`) | Wait for running dependencies with `check --wait`; retry or unblock failed ones; inspect `dispatch-show` if already dispatched |\n| `inject_rejected` | `--inject` refused because no recognized agent runs in the target (`data.terminal`, `data.reason`) | Start a recognized agent there or pick another terminal; or dispatch without `--inject` and use `terminal send` |\n| `runtime_error` | Any other failure, including a target terminal that already owns an active Dispatch | Read the message, inspect state, and do not retry unchanged |\n\n## Retry, stop, and abandon\n\nRetry only a positively proven failed or stopped attempt. Name the failed Task\nwith `--task`, since `--spec` creates a new one. Placement is never silently\ninherited:\n\n```text\nORCA orchestration worker-start --task --retry-of --worktree --agent --json\n```\n\nAfter three consecutive failures for one Task, its dispatch context\ncircuit-breaks and the Task is failed. Do not route around that boundary with a\nnew Run or an unrelated Dispatch.\n\nFor `outcome_unknown`, inspect first, then make an explicit choice:\n\n```text\nORCA orchestration worker-stop --dispatch --json\nORCA orchestration worker-abandon --dispatch --json\n```\n\n`worker-stop` closes only the exact proven supervised agent terminal. It never\ndeletes the worktree, setup terminal, configured tabs, or unrelated processes.\n`worker-abandon` fences orchestration while accepting that resources may remain\nlive; it performs no remote, process, or filesystem action. When it settles a\nworker, it retains an owned terminal by the same rule as `worker-retain`, so Orca\nstops owing its release; a release already committed (`releasing`,\n`release_unknown`) is left as it is. An already-settled worker is left untouched.\n\nTo cancel a Task, settle its worker with `worker-stop` or `worker-abandon`, then\nrecord the reason. A cancelled Task is `failed`, so its dependents stay blocked\nuntil a `--retry-of` replacement completes it:\n\n```text\nORCA orchestration task-update --id --status failed --result cancelled --json\n```\n\n## Retain and release\n\n```text\nORCA orchestration worker-retain --dispatch --json\nORCA orchestration worker-release --dispatch --json\n```\n\nRetain only when the user explicitly wants the settled terminal kept live.\nRelease works after succeeded and failed reports, archives readable output, and\ncloses only the exact terminal owned by that settled Dispatch. Replays may call\nrelease again safely. Reused, pre-existing, setup, coordinator, active,\nuser-taken-over, and unproven terminals are retained.\n\nA `worker-start` that failed before its agent was ready still owns the terminal\nit created. Its receipt names `worker-release`, and `worker-list` reports that\nrow as `reclaimable`; release it there rather than closing the terminal by hand.\n\nNever release because of timeout, TUI idle, heartbeat, status, question,\nescalation, or stale/rejected completion. If the receipt says `release_pending`\nor `release_unknown`, follow its exact recovery action. Never substitute\n`terminal close`.\n\n`orchestration reset` is destructive recovery. Do not run it during active\ncoordination unless the user explicitly abandons that state.\n\n\n\n# Worker contract\n\nThe injected preamble is authoritative. Copy its command rather than\nreconstructing flags. In particular, preserve the exact executable, worker\nhandle, Task ID, and Dispatch ID, and keep any other flag it carries (an older\nOrca host adds `--dispatch-capability`).\n\n## Heartbeat\n\nSend heartbeats only at the cadence required by the live preamble. Skip them\nwhile blocked inside `ask` or `check --wait`; those calls are liveness signals.\n\n```text\nORCA orchestration send --from --type heartbeat --subject \"alive\" --task-id --dispatch-id --phase \"\"\n```\n\nUse typed lifecycle flags, not a hand-written JSON payload. A heartbeat proves\nliveness, never completion.\n\n## Ask and resume\n\nUse Orca `ask` whenever the coordinator must answer. Never open a local question\nTUI the coordinator cannot answer.\n\n```text\nORCA orchestration ask --from --question \"\" --options \",\" --timeout-ms 600000\n\nORCA orchestration ask --from --resume --timeout-ms 600000\n```\n\nA timeout or disconnect leaves the original question pending. Resume its\nmessage ID; do not create a duplicate question.\n\n## Reading coordinator follow-ups\n\nThe coordinator steers a running worker with `send --to dispatch:`. That\nenqueue is durable but does not interrupt you, so nothing arrives unless you\nlook:\n\n```text\nORCA orchestration check --terminal --json\n```\n\nRun it at each natural checkpoint — before starting a new file, after a test\nrun — and once more immediately before `worker_done`, so a redirect or a\ncancellation lands before the Task settles. `check` names its caller with\n`--terminal`, never `--from`. Stop checking after `worker_done`.\n\nIf `check` returns `consumer_fenced`, this process no longer owns its Dispatch:\nthe Attempt was re-attached to another worker or settled without you. Stop, do\nnot send `worker_done`, and do not retry the check. An empty `check` never means\nyou were replaced; `consumer_fenced` is the only way you learn that.\nIf `send` or `ask` returns `consumer_fenced`, the command ran from another\nparty's terminal (a coordinator or another worker); run it from your own terminal.\n\n## Escalation\n\nEscalate only before completion and only when the coordinator must intervene:\n\n```text\nORCA orchestration send --from --type escalation --subject \"Blocked: \" --body \"
\" --task-id --dispatch-id \n```\n\n## Completion\n\nSend exactly one terminal report. `--body` is three sentences: what changed,\nwhat was found, and what remains. Use `--outcome failed` when the requested work\nis not complete; never hide failure in prose or silently exit.\n\nAppend `--files-modified` or `--report-path` only when applicable, using actual\npaths. Do not send documentation placeholders as metadata.\n\n```text\nORCA orchestration send --from --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded\n```\n\nAfter `worker_done`, end the dispatched turn and idle. Do not poll, close your\nown terminal, or begin unrelated work. A later direct user instruction is new\nuser-owned work and must not reuse settled lifecycle IDs; a supervised follow-up\narrives with a fresh preamble and Task block.\n" // oxfmt-ignore const ORCHESTRATION_COORDINATOR_LOOP_REFERENCE_MARKDOWN = "# Coordinator loop\n\nLoad this reference for expanded DAG waves, per-invocation launch preferences,\nsame-terminal reuse, or review ownership. The compact guide remains the source\nof truth for the loop order and completion boundary.\n\n## Ready waves\n\nCreate independent Tasks before the first wait. Encode only real dependencies,\nthen use the ready view as external memory:\n\n```text\nORCA orchestration task-create --spec \"\" --deps --json\nORCA orchestration task-list --ready --brief --json\n```\n\n`--brief` collapses whitespace and caps echoed specs at 160 characters;\n`spec_truncated` identifies shortened rows. Omit it when full specs are needed or\nwhen an older CLI rejects the flag. A nested worker must respect\n`nested_worker_depth_exceeded`; creating another Run does not reset depth.\n\n## Launch preferences\n\nFor a fresh Claude, Codex, Cursor, Antigravity, or Muse terminal, `--model`\naccepts an opaque provider model ID. Pass it only when the user named a model;\notherwise omit it so the worker inherits the user's configured agent default.\nAdd `--effort` only when that model supports it:\n\n```text\nORCA orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json\nORCA orchestration worker-start --task --worktree current --agent muse --model muse-spark-1.3 --json\n```\n\nOther agents, including `opencode`, reject `--model`; they run the model set in\ntheir own config, so a coordinator wanting a same-model opencode worker relies\non that config.\n\n`--effort` requires `--model`; neither option combines with `--terminal`. A\nconnected worker server must advertise launch-preference support before Orca\nforwards either field. Compare `launch.requested` with `launch.effective`; never\nclaim a model or effort from requested arguments alone.\n\n## Reuse after settlement\n\nChoose the terminal's next owner before acknowledging the Delivery. When the\nsame exact agent has immediate follow-up work, recover the proven handle and\ntransfer cleanup ownership to the new Dispatch:\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-start --task --terminal --json\n```\n\nOtherwise explicitly retain or release the settled worker. Do not leave it live\nonly to inspect output; archived output remains available through `worker-read`.\n\n## Review ownership\n\nA review-only `worker_done` authorizes synthesis of findings, not coordinator\nfile edits. Dispatch or hand off fixes unless the user explicitly assigned them\nto the coordinator. If the user's plan names a next owner, post-review fixes and\nPR preparation remain with that owner; the coordinator routes and synthesizes.\n" @@ -81,16 +81,16 @@ const ORCHESTRATION_LEGACY_CONTRACT_MIGRATION_REFERENCE_MARKDOWN = "# Legacy con const ORCHESTRATION_LOW_LEVEL_TOPOLOGY_REFERENCE_MARKDOWN = "# Low-level topology\n\nLoad this reference only when `worker-start` cannot express required custom argv\nor terminal topology. It is not the normal supervised loop and is never a full\nhandoff recipe.\n\n```text\nORCA terminal create --worktree active --title --command \"\" --json\nORCA terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\nORCA orchestration dispatch --task --to --inject --json\n```\n\nWait for readiness only when startup could lose injected input. Prefer\nagent-first `worker-start` whenever its argv and topology are sufficient.\n\n`dispatch --inject` creates authoritative Task/Dispatch context but deliberately\nkeeps an operator-created process unsupervised: it creates no supervised worker\nresource row. `worker-show`, `worker-read`, and `worker-list` report the lane as\n`unsupervised`; `worker-stop` and `worker-abandon` do not close that process, and\nsettled retain/release take no process action.\n\nUse `worker-start --terminal ` when lifecycle ownership of an existing\nagent terminal is required. Never imply that low-level dispatch retroactively\nowns a process, never use it to route around the nested-depth limit, and never\nuse it for an ownership handoff.\n" // oxfmt-ignore -const ORCHESTRATION_MESSAGING_AND_GATES_REFERENCE_MARKDOWN = "# Messaging and gates\n\nLoad this reference for inbox replay, attempt-specific guidance, group\naddresses, blocking questions, or coordinator-managed DAG decisions.\n\nA successful `send` proves durable enqueue. Wake and nudge are best-effort\nattention only: neither proves the recipient read the message, began a turn, or\naccepted steering.\n\n## Coordinator delivery loop\n\n`check` names its caller with `--terminal ` and is the only verb that\nrejects `--from`. Omit `--terminal` inside an Orca terminal, where Orca resolves\nthe caller; pass it explicitly from anywhere else, including a dispatched\nworker reading coordinator follow-ups.\n\nA consuming coordinator `check` returns the bound Run's oldest FIFO Delivery,\nup to 50 messages, and replays that exact batch until acknowledged. Process\nevery row and required terminal ownership decision before `--ack`. Type filters\ndecide when a waiter wakes; they do not authorize skipping older actionable\nmail. A Delivery therefore always carries the whole FIFO batch whatever its\ntypes, and a `check` without `--wait` hands that batch over unfiltered.\n`--peek` and `--all` are read-only inspection, not progress through the\ncoordinator inbox.\n\nAn empty wait or timeout is a checkpoint. Continue rolling waits until every\nexpected Dispatch settles. Heartbeat or visible activity means alive, not done.\n\n## Addresses\n\nUse a stable Dispatch address for attempt-specific coordinator guidance:\n\n```text\nORCA orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nDo not substitute a remote terminal handle. Omit `--from` for ordinary\ncoordinator calls; a dispatched worker instead copies the exact `--from` and\ncapability arguments in its preamble. `check` is the exception: it identifies\nits caller with `--terminal`, never `--from`.\n\nGroup addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`,\n`@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`. Every group but\n`@worktree:` means the live Dispatches of the sender's own Run. Mail goes\nto each `dispatch:` mailbox, except a worker coordinating a child Run\nreceives it in that `run:` mailbox. A sender bound to no Run is refused;\n`--run` must match the group audience and never grants membership.\nA Run group excludes its owning coordinator; a worker raising a blocker sends\nto `run:`. A worker that created its own Run addresses that Run's workers,\nnot its siblings. `@worktree:` reaches matching workspace terminals,\nincluding coordinators. Use groups only for intentional fan-out status or\nquestions. `worker_done`, heartbeat, and other\nDispatch lifecycle messages never target groups.\n\n## Questions and gates\n\nA worker uses `ask`; its timeout leaves one durable question pending, which the\nworker resumes by message ID. The coordinator answers that message with `reply`.\n\nUse a gate only for a coordinator-owned Task-DAG decision:\n\n```text\nORCA orchestration gate-create --task --question \"\" --options --json\nORCA orchestration gate-resolve --id --resolution \"\" --json\nORCA orchestration gate-list --task --json\n```\n\nPass `json_array` using the quoting rules of the active shell; do not copy POSIX\nsingle-quote syntax into PowerShell or `cmd.exe`.\n\nDo not create a gate merely to answer a worker's `ask`.\n" +const ORCHESTRATION_MESSAGING_AND_GATES_REFERENCE_MARKDOWN = "# Messaging and gates\n\nLoad this reference for inbox replay, attempt-specific guidance, group\naddresses, blocking questions, or coordinator-managed DAG decisions.\n\nA successful `send` proves durable enqueue. Wake and nudge are best-effort\nattention only: neither proves the recipient read the message, began a turn, or\naccepted steering.\n\n## Coordinator delivery loop\n\n`check` names its caller with `--terminal ` and is the only verb that\nrejects `--from`. Omit `--terminal` inside an Orca terminal, where Orca resolves\nthe caller; pass it explicitly from anywhere else, including a dispatched\nworker reading coordinator follow-ups.\n\nA consuming coordinator `check` returns the bound Run's oldest FIFO Delivery,\nup to 50 messages, and replays that exact batch until acknowledged. Process\nevery row and required terminal ownership decision before `--ack`. Type filters\ndecide when a waiter wakes; they do not authorize skipping older actionable\nmail. A Delivery therefore always carries the whole FIFO batch whatever its\ntypes, and a `check` without `--wait` hands that batch over unfiltered.\n`--peek` and `--all` are read-only inspection, not progress through the\ncoordinator inbox.\n\nAn empty wait or timeout is a checkpoint. Continue rolling waits until every\nexpected Dispatch settles. Heartbeat or visible activity means alive, not done.\n\n## Addresses\n\nUse a stable Dispatch address for attempt-specific coordinator guidance:\n\n```text\nORCA orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nDo not substitute a remote terminal handle. Omit `--from` for ordinary\ncoordinator calls; a dispatched worker instead copies the exact `--from` and\nother arguments in its preamble. `check` is the exception: it identifies\nits caller with `--terminal`, never `--from`.\n\nGroup addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`,\n`@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`. Every group but\n`@worktree:` means the live Dispatches of the sender's own Run. Mail goes\nto each `dispatch:` mailbox, except a worker coordinating a child Run\nreceives it in that `run:` mailbox. A sender bound to no Run is refused;\n`--run` must match the group audience and never grants membership.\nA Run group excludes its owning coordinator; a worker raising a blocker sends\nto `run:`. A worker that created its own Run addresses that Run's workers,\nnot its siblings. `@worktree:` reaches matching workspace terminals,\nincluding coordinators. Use groups only for intentional fan-out status or\nquestions. `worker_done`, heartbeat, and other\nDispatch lifecycle messages never target groups.\n\n## Questions and gates\n\nA worker uses `ask`; its timeout leaves one durable question pending, which the\nworker resumes by message ID. The coordinator answers that message with `reply`.\n\nUse a gate only for a coordinator-owned Task-DAG decision:\n\n```text\nORCA orchestration gate-create --task --question \"\" --options --json\nORCA orchestration gate-resolve --id --resolution \"\" --json\nORCA orchestration gate-list --task --json\n```\n\nPass `json_array` using the quoting rules of the active shell; do not copy POSIX\nsingle-quote syntax into PowerShell or `cmd.exe`.\n\nDo not create a gate merely to answer a worker's `ask`.\n" // oxfmt-ignore const ORCHESTRATION_PLACEMENT_AND_REMOTE_REFERENCE_MARKDOWN = "# Placement and remote execution\n\nLoad this reference before creating a new worktree or placing work through SSH,\nWSL, or another connected Orca server.\n\n## Placement choices\n\nA fresh worker means a fresh agent terminal, not a new Git worktree. Use the\ncurrent or an exact existing workspace by default. Create a worktree only when\nthe user requested one or a concrete checkout or filesystem conflict makes\nsharing unsafe.\n\n```text\n# Current workspace; setup is not rerun.\nORCA orchestration worker-start --task --worktree current --agent codex --json\n\n# Stacked child worktree.\nORCA orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n\n# Independent top-level worktree.\nORCA orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nCurrent and exact existing workspaces create a fresh terminal unless\n`--terminal` is explicit. Folder workspaces are first-class; do not invoke Git\nor require worktree lineage when the selected workspace is a folder.\n\nRegister a folder workspace through project setup. `repo add --path `\nrequires a valid Git repository and rejects a plain directory:\n\n```text\nORCA project setup-existing-folder --project --host --path --kind folder --json\n```\n\nThen place work on the returned workspace with an exact selector. A worktree\nselector needs the full `::` value Orca returned, passed as\n`id:`; a bare repo id is not a worktree id. `new-child` and\n`new-top-level` are worktree creation and do not apply to a folder.\n\nNew worktrees use agent-first creation and run setup by default. Preserve the\nrepository's startup policy: `start-immediately` can report setup as `running`,\nwhile `wait-for-setup` gates prompt delivery on success. Orca lineage, Git base,\nfilesystem isolation, coordination parentage, UI grouping, and execution host\nare separate decisions.\n\n## Connected servers\n\nThe Run and Tasks remain authoritative on the current server. `--on` selects\nonly the worker's execution server and appears only on `worker-start`:\n\n```text\nORCA orchestration worker-start --task --on --worktree new-top-level --repo --name --agent codex --setup run --json\n```\n\nRemote `current` and `new-child` are invalid because they are ambiguous across\nservers. Use an exact discovered remote workspace, or `new-top-level` with an\nexact remote repository selector. After start, route every follow-up, read,\nstop, and cleanup by Dispatch ID; never repeat `--on` or substitute a remote\nterminal handle.\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-read --dispatch --limit 50 --json\nORCA orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\nORCA orchestration worker-list --run --include-remote --json\n```\n\n`worker-list` reads local fleet state only; enumerate remote workers with\n`--include-remote` or every one of them reads `unverifiable`. Scope every list\nwith `--run `: unscoped, it reports every Dispatch this runtime has\nrecorded, and the workers you are waiting on are lost in that history.\n\n## Execution-host and mixed-version floor\n\nThe execution host owns process, filesystem, transcript, stop, and cleanup\nfacts. Render only `live`, `unverifiable`, or `exited`. Connection loss, relay\nabsence, missing client inventory, or timeout yields `unverifiable`, never\nsynthetic exit and never a client-local substitute action.\n\nClients and servers update independently. Optional response fields may be\nabsent. Forward model/effort, transcript reads, cleanup, or another new remote\noperation only when the peer advertises the relevant capability; unknown stream\nopcodes can be silently dropped. A narrow unsupported response may degrade to a\ndocumented older path, but must not broaden the target or cross the execution\nboundary. Changing host-published content reaches old clients even without a\nwire-shape change, so preserve established semantics or negotiate the behavior.\n\nFor WSL, use the exact executable and arguments returned by Orca so the distro\nand packaged launcher remain bound. Do not translate a printed `orca-ide`\nrecovery command into a PATH-resolved local command.\n" // oxfmt-ignore -const ORCHESTRATION_RECOVERY_AND_CLEANUP_REFERENCE_MARKDOWN = "# Recovery and cleanup\n\nLoad this reference only after a failed/stopped/unknown attempt, explicit retry\ndecision, stop/abandon request, retention request, or uncertain release.\n\n| Proven state | Safe action |\n| ----------------------- | ------------------------------------------------------------------ |\n| `ready` or active | Keep waiting; optionally read bounded output |\n| `failed` or `stopped` | Start a replacement with `--retry-of`; repeat placement explicitly |\n| `outcome_unknown` | Inspect, then choose `worker-stop` or explicit `worker-abandon` |\n| Accepted `worker_done` | Reuse, retain, or release |\n| Remote contact lost | Preserve `unverifiable`; do not stop or retry from absence alone |\n| `unverifiable` liveness | Keep waiting or inspect; never stop, abandon, retry, or release |\n| Proven `exited` agent | Enumerate with `worker-list`; follow its `nextAction` |\n\n## Inspect before acting\n\n```text\nORCA orchestration worker-list --run --json\nORCA orchestration worker-list --run --include-remote --json\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-read --dispatch --limit 50 --json\n```\n\n`worker-list` is the enumerating command and the authority on agent liveness:\neach row carries `projection.liveness`, `projection.attention.categories`,\n`projection.attention.requiresAction`, and a literal `projection.nextAction`\nargv to run. Always scope it with `--run `; an unscoped list reports\nevery Dispatch this runtime has ever recorded and buries the live ones.\n`worker-show`'s `observation.status` is PTY liveness only, so a `live` terminal\nwhose agent died at a trust prompt still reads `live` there.\n\nWhen the two disagree, the fleet verdict decides — unless the fleet row is\n`unverifiable` for a reason that names a gap on this client rather than a fact\nabout the worker. `missing_status`, `host_unavailable`, and\n`capability_unsupported` are such gaps: the first means this runtime holds no\nstatus row, the second that it could not ask the execution host at all, and the\nthird that a stale peer answered but lacks the fleet-snapshot capability.\nAgainst any of them, a `worker-show` verdict sourced from the execution host is\nthe better evidence and outranks the row. Only `host_unavailable` is contact\nloss; the other two mean the host was never asked or answered without the\ncapability.\n\nThis never promotes absence. `unverifiable` from either command still authorizes\nnothing — only a positive `live` or `exited` verdict does.\n\nA worker started with `--on ` reads `unverifiable` until you\nenumerate with `--include-remote`, which asks its execution host for the\nverdict. Rows come newest first and past 100 the response pages, so follow\n`page.nextCursor` with `--cursor ` until `page.hasMore` is false.\n\n## Stall needs positive evidence\n\nLeave the wait only on positive proof the agent stopped: `exited` liveness, the\nworker's own observation of process exit, or a transcript whose final agent turn\nsent no `worker_done`. Only then choose `worker-stop` or `worker-abandon`.\n\n`unverifiable` is always absence — `missing_status`, `stale_status`,\n`restored_unconfirmed`, or a remote worker with no connection — and a null\n`agentWait` or an unchanged `worker-read` tail is that same absence seen again.\nAbsence never authorizes stop, abandon, retry, or release: keep waiting, or\ninspect until you hold one of the positive signals above. A `nextAction` that\nnames an inspecting command is asking for evidence, not for cleanup.\n\n`worker-read --source auto` uses a proven provider transcript when available and\notherwise returns bounded terminal output with a typed `fallbackReason`.\nContinue with its top-level cursor, which is pinned to that source. If Orca\nreports `source_changed`, restart without the old cursor. A bounded initial\ntranscript tail can return an EOF cursor that follows only newly appended records;\nread `contentComplete`, `clipping`, and `warnings` before assuming omitted older\nrecords are pageable. Never guess a provider session ID, transcript path, or\nremote terminal handle.\n\n## Was the mutation applied?\n\nWhen a mutation's response was lost and named no Dispatch, do not replay blind.\nEvery orchestration mutation accepts `--retry-request `, which reuses one\noperation identity so Orca can replay, join, or recover it instead of starting a\nduplicate. Ask what happened first:\n\n```text\nORCA orchestration request-show --request --json\n```\n\n`completed` means the mutation already took effect; read its recorded receipt\ninstead of rerunning. `pending` means the original mutation is still running or\nOrca restarted before recording its outcome; replay the original command with\n`--retry-request `. `absent` means this runtime holds no receipt\nunder your caller identity — that is not proof nothing happened, so inspect the\naffected Task, Dispatch, and terminal before deciding whether to retry.\n\nWhen a worker's terminal accepted input but the submit is unconfirmed, use\n`terminal send --wait-submit `: it observes the accepted prompt for that\nlong and, on timeout, returns the input-accepted receipt without resending.\n\n## Refused starts\n\n`dispatch` and `worker-start` refuse the following preflight cases with a stable\n`error.code`; read it before choosing a recovery, and treat `error.data.nextSteps`\nas the exact recovery text. Older hosts may omit `data`, so treat every field as\noptional.\n\n| Code | Meaning | Recovery |\n| -------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |\n| `task_not_found` | No Task with that id, or not in the bound Run (`data.taskId`, `data.runId`) | Check `task-list --json`; create the Task with `task-create` if it does not exist |\n| `task_not_startable` | Task cannot start now: not `ready`, or invalid `--retry-of` (`data.status`, `data.unmetDependencies`, `data.retryOf`) | Wait for running dependencies with `check --wait`; retry or unblock failed ones; inspect `dispatch-show` if already dispatched |\n| `inject_rejected` | `--inject` refused because no recognized agent runs in the target (`data.terminal`, `data.reason`) | Start a recognized agent there or pick another terminal; or dispatch without `--inject` and use `terminal send` |\n| `runtime_error` | Any other failure, including a target terminal that already owns an active Dispatch | Read the message, inspect state, and do not retry unchanged |\n\n## Retry, stop, and abandon\n\nRetry only a positively proven failed or stopped attempt. Name the failed Task\nwith `--task`, since `--spec` creates a new one. Placement is never silently\ninherited:\n\n```text\nORCA orchestration worker-start --task --retry-of --worktree --agent --json\n```\n\nAfter three consecutive failures for one Task, its dispatch context\ncircuit-breaks and the Task is failed. Do not route around that boundary with a\nnew Run or an unrelated Dispatch.\n\nFor `outcome_unknown`, inspect first, then make an explicit choice:\n\n```text\nORCA orchestration worker-stop --dispatch --json\nORCA orchestration worker-abandon --dispatch --json\n```\n\n`worker-stop` closes only the exact proven supervised agent terminal. It never\ndeletes the worktree, setup terminal, configured tabs, or unrelated processes.\n`worker-abandon` fences orchestration while accepting that resources may remain\nlive; it performs no remote, process, or filesystem action.\n\n## Retain and release\n\n```text\nORCA orchestration worker-retain --dispatch --json\nORCA orchestration worker-release --dispatch --json\n```\n\nRetain only when the user explicitly wants the settled terminal kept live.\nRelease works after succeeded and failed reports, archives readable output, and\ncloses only the exact terminal owned by that settled Dispatch. Replays may call\nrelease again safely. Reused, pre-existing, setup, coordinator, active,\nuser-taken-over, and unproven terminals are retained.\n\nA `worker-start` that failed before its agent was ready still owns the terminal\nit created. Its receipt names `worker-release`, and `worker-list` reports that\nrow as `reclaimable`; release it there rather than closing the terminal by hand.\n\nNever release because of timeout, TUI idle, heartbeat, status, question,\nescalation, or stale/rejected completion. If the receipt says `release_pending`\nor `release_unknown`, follow its exact recovery action. Never substitute\n`terminal close`.\n\n`orchestration reset` is destructive recovery. Do not run it during active\ncoordination unless the user explicitly abandons that state.\n" +const ORCHESTRATION_RECOVERY_AND_CLEANUP_REFERENCE_MARKDOWN = "# Recovery and cleanup\n\nLoad this reference only after a failed/stopped/unknown attempt, explicit retry\ndecision, stop/abandon request, retention request, or uncertain release.\n\n| Proven state | Safe action |\n| ----------------------- | ------------------------------------------------------------------ |\n| `ready` or active | Keep waiting; optionally read bounded output |\n| `failed` or `stopped` | Start a replacement with `--retry-of`; repeat placement explicitly |\n| `outcome_unknown` | Inspect, then choose `worker-stop` or explicit `worker-abandon` |\n| Accepted `worker_done` | Reuse, retain, or release |\n| Remote contact lost | Preserve `unverifiable`; do not stop or retry from absence alone |\n| `unverifiable` liveness | Keep waiting or inspect; never stop, abandon, retry, or release |\n| Proven `exited` agent | Enumerate with `worker-list`; follow its `nextAction` |\n\n## Inspect before acting\n\n```text\nORCA orchestration worker-list --run --json\nORCA orchestration worker-list --run --include-remote --json\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-read --dispatch --limit 50 --json\n```\n\n`worker-list` is the enumerating command and the authority on agent liveness:\neach row carries `projection.liveness`, `projection.attention.categories`,\n`projection.attention.requiresAction`, and a literal `projection.nextAction`\nargv to run. Always scope it with `--run `; an unscoped list reports\nevery Dispatch this runtime has ever recorded and buries the live ones.\n`worker-show`'s `observation.status` is PTY liveness only, so a `live` terminal\nwhose agent died at a trust prompt still reads `live` there.\n\nWhen the two disagree, the fleet verdict decides — unless the fleet row is\n`unverifiable` for a reason that names a gap on this client rather than a fact\nabout the worker. `missing_status`, `host_unavailable`, and\n`capability_unsupported` are such gaps: the first means this runtime holds no\nstatus row, the second that it could not ask the execution host at all, and the\nthird that a stale peer answered but lacks the fleet-snapshot capability.\nAgainst any of them, a `worker-show` verdict sourced from the execution host is\nthe better evidence and outranks the row. Only `host_unavailable` is contact\nloss; the other two mean the host was never asked or answered without the\ncapability.\n\nThis never promotes absence. `unverifiable` from either command still authorizes\nnothing — only a positive `live` or `exited` verdict does.\n\nA worker started with `--on ` reads `unverifiable` until you\nenumerate with `--include-remote`, which asks its execution host for the\nverdict. Rows come newest first and past 100 the response pages, so follow\n`page.nextCursor` with `--cursor ` until `page.hasMore` is false.\n\n## Stall needs positive evidence\n\nLeave the wait only on positive proof the agent stopped: `exited` liveness, the\nworker's own observation of process exit, or a transcript whose final agent turn\nsent no `worker_done`. Only then choose `worker-stop` or `worker-abandon`.\n\n`unverifiable` is always absence — `missing_status`, `stale_status`,\n`restored_unconfirmed`, or a remote worker with no connection — and a null\n`agentWait` or an unchanged `worker-read` tail is that same absence seen again.\nAbsence never authorizes stop, abandon, retry, or release: keep waiting, or\ninspect until you hold one of the positive signals above. A `nextAction` that\nnames an inspecting command is asking for evidence, not for cleanup.\n\n`worker-read --source auto` uses a proven provider transcript when available and\notherwise returns bounded terminal output with a typed `fallbackReason`.\nContinue with its top-level cursor, which is pinned to that source. If Orca\nreports `source_changed`, restart without the old cursor. A bounded initial\ntranscript tail can return an EOF cursor that follows only newly appended records;\nread `contentComplete`, `clipping`, and `warnings` before assuming omitted older\nrecords are pageable. Never guess a provider session ID, transcript path, or\nremote terminal handle.\n\n## Was the mutation applied?\n\nWhen a mutation's response was lost and named no Dispatch, do not replay blind.\nEvery orchestration mutation accepts `--retry-request `, which reuses one\noperation identity so Orca can replay, join, or recover it instead of starting a\nduplicate. Ask what happened first:\n\n```text\nORCA orchestration request-show --request --json\n```\n\n`completed` means the mutation already took effect; read its recorded receipt\ninstead of rerunning. `pending` means the original mutation is still running or\nOrca restarted before recording its outcome; replay the original command with\n`--retry-request `. `absent` means this runtime holds no receipt\nunder your caller identity — that is not proof nothing happened, so inspect the\naffected Task, Dispatch, and terminal before deciding whether to retry.\n\nWhen a worker's terminal accepted input but the submit is unconfirmed, use\n`terminal send --wait-submit `: it observes the accepted prompt for that\nlong and, on timeout, returns the input-accepted receipt without resending.\n\n## Refused starts\n\n`dispatch` and `worker-start` refuse the following preflight cases with a stable\n`error.code`; read it before choosing a recovery, and treat `error.data.nextSteps`\nas the exact recovery text. Older hosts may omit `data`, so treat every field as\noptional.\n\n| Code | Meaning | Recovery |\n| -------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |\n| `task_not_found` | No Task with that id, or not in the bound Run (`data.taskId`, `data.runId`) | Check `task-list --json`; create the Task with `task-create` if it does not exist |\n| `task_not_startable` | Task cannot start now: not `ready`, or invalid `--retry-of` (`data.status`, `data.unmetDependencies`, `data.retryOf`) | Wait for running dependencies with `check --wait`; retry or unblock failed ones; inspect `dispatch-show` if already dispatched |\n| `inject_rejected` | `--inject` refused because no recognized agent runs in the target (`data.terminal`, `data.reason`) | Start a recognized agent there or pick another terminal; or dispatch without `--inject` and use `terminal send` |\n| `runtime_error` | Any other failure, including a target terminal that already owns an active Dispatch | Read the message, inspect state, and do not retry unchanged |\n\n## Retry, stop, and abandon\n\nRetry only a positively proven failed or stopped attempt. Name the failed Task\nwith `--task`, since `--spec` creates a new one. Placement is never silently\ninherited:\n\n```text\nORCA orchestration worker-start --task --retry-of --worktree --agent --json\n```\n\nAfter three consecutive failures for one Task, its dispatch context\ncircuit-breaks and the Task is failed. Do not route around that boundary with a\nnew Run or an unrelated Dispatch.\n\nFor `outcome_unknown`, inspect first, then make an explicit choice:\n\n```text\nORCA orchestration worker-stop --dispatch --json\nORCA orchestration worker-abandon --dispatch --json\n```\n\n`worker-stop` closes only the exact proven supervised agent terminal. It never\ndeletes the worktree, setup terminal, configured tabs, or unrelated processes.\n`worker-abandon` fences orchestration while accepting that resources may remain\nlive; it performs no remote, process, or filesystem action. When it settles a\nworker, it retains an owned terminal by the same rule as `worker-retain`, so Orca\nstops owing its release; a release already committed (`releasing`,\n`release_unknown`) is left as it is. An already-settled worker is left untouched.\n\nTo cancel a Task, settle its worker with `worker-stop` or `worker-abandon`, then\nrecord the reason. A cancelled Task is `failed`, so its dependents stay blocked\nuntil a `--retry-of` replacement completes it:\n\n```text\nORCA orchestration task-update --id --status failed --result cancelled --json\n```\n\n## Retain and release\n\n```text\nORCA orchestration worker-retain --dispatch --json\nORCA orchestration worker-release --dispatch --json\n```\n\nRetain only when the user explicitly wants the settled terminal kept live.\nRelease works after succeeded and failed reports, archives readable output, and\ncloses only the exact terminal owned by that settled Dispatch. Replays may call\nrelease again safely. Reused, pre-existing, setup, coordinator, active,\nuser-taken-over, and unproven terminals are retained.\n\nA `worker-start` that failed before its agent was ready still owns the terminal\nit created. Its receipt names `worker-release`, and `worker-list` reports that\nrow as `reclaimable`; release it there rather than closing the terminal by hand.\n\nNever release because of timeout, TUI idle, heartbeat, status, question,\nescalation, or stale/rejected completion. If the receipt says `release_pending`\nor `release_unknown`, follow its exact recovery action. Never substitute\n`terminal close`.\n\n`orchestration reset` is destructive recovery. Do not run it during active\ncoordination unless the user explicitly abandons that state.\n" // oxfmt-ignore -const ORCHESTRATION_WORKER_CONTRACT_REFERENCE_MARKDOWN = "# Worker contract\n\nThe injected preamble is authoritative. Copy its command rather than\nreconstructing flags. In particular, preserve the exact executable, worker\nhandle, Dispatch capability, Task ID, and Dispatch ID.\n\n## Heartbeat\n\nSend heartbeats only at the cadence required by the live preamble. Skip them\nwhile blocked inside `ask` or `check --wait`; those calls are liveness signals.\n\n```text\nORCA orchestration send --from --dispatch-capability --type heartbeat --subject \"alive\" --task-id --dispatch-id --phase \"\"\n```\n\nUse typed lifecycle flags, not a hand-written JSON payload. A heartbeat proves\nliveness, never completion.\n\n## Ask and resume\n\nUse Orca `ask` whenever the coordinator must answer. Never open a local question\nTUI the coordinator cannot answer.\n\n```text\nORCA orchestration ask --from --dispatch-capability --question \"\" --options \",\" --timeout-ms 600000\n\nORCA orchestration ask --from --dispatch-capability --resume --timeout-ms 600000\n```\n\nA timeout or disconnect leaves the original question pending. Resume its\nmessage ID; do not create a duplicate question.\n\n## Reading coordinator follow-ups\n\nThe coordinator steers a running worker with `send --to dispatch:`. That\nenqueue is durable but does not interrupt you, so nothing arrives unless you\nlook:\n\n```text\nORCA orchestration check --terminal --json\n```\n\nRun it at each natural checkpoint — before starting a new file, after a test\nrun — and once more immediately before `worker_done`, so a redirect or a\ncancellation lands before the Task settles. `check` names its caller with\n`--terminal`, never `--from`. Stop checking after `worker_done`.\n\nIf `check` returns `consumer_fenced`, this process no longer owns its Dispatch:\nthe Attempt was re-attached to another worker or settled without you. Stop, do\nnot send `worker_done`, and do not retry the check. An empty `check` never means\nyou were replaced; `consumer_fenced` is the only way you learn that.\n\n## Escalation\n\nEscalate only before completion and only when the coordinator must intervene:\n\n```text\nORCA orchestration send --from --dispatch-capability --type escalation --subject \"Blocked: \" --body \"
\" --task-id --dispatch-id \n```\n\n## Completion\n\nSend exactly one terminal report. `--body` is three sentences: what changed,\nwhat was found, and what remains. Use `--outcome failed` when the requested work\nis not complete; never hide failure in prose or silently exit.\n\nAppend `--files-modified` or `--report-path` only when applicable, using actual\npaths. Do not send documentation placeholders as metadata.\n\n```text\nORCA orchestration send --from --dispatch-capability --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded\n```\n\nAfter `worker_done`, end the dispatched turn and idle. Do not poll, close your\nown terminal, or begin unrelated work. A later direct user instruction is new\nuser-owned work and must not reuse settled lifecycle IDs; a supervised follow-up\narrives with a fresh preamble and Task block.\n" +const ORCHESTRATION_WORKER_CONTRACT_REFERENCE_MARKDOWN = "# Worker contract\n\nThe injected preamble is authoritative. Copy its command rather than\nreconstructing flags. In particular, preserve the exact executable, worker\nhandle, Task ID, and Dispatch ID, and keep any other flag it carries (an older\nOrca host adds `--dispatch-capability`).\n\n## Heartbeat\n\nSend heartbeats only at the cadence required by the live preamble. Skip them\nwhile blocked inside `ask` or `check --wait`; those calls are liveness signals.\n\n```text\nORCA orchestration send --from --type heartbeat --subject \"alive\" --task-id --dispatch-id --phase \"\"\n```\n\nUse typed lifecycle flags, not a hand-written JSON payload. A heartbeat proves\nliveness, never completion.\n\n## Ask and resume\n\nUse Orca `ask` whenever the coordinator must answer. Never open a local question\nTUI the coordinator cannot answer.\n\n```text\nORCA orchestration ask --from --question \"\" --options \",\" --timeout-ms 600000\n\nORCA orchestration ask --from --resume --timeout-ms 600000\n```\n\nA timeout or disconnect leaves the original question pending. Resume its\nmessage ID; do not create a duplicate question.\n\n## Reading coordinator follow-ups\n\nThe coordinator steers a running worker with `send --to dispatch:`. That\nenqueue is durable but does not interrupt you, so nothing arrives unless you\nlook:\n\n```text\nORCA orchestration check --terminal --json\n```\n\nRun it at each natural checkpoint — before starting a new file, after a test\nrun — and once more immediately before `worker_done`, so a redirect or a\ncancellation lands before the Task settles. `check` names its caller with\n`--terminal`, never `--from`. Stop checking after `worker_done`.\n\nIf `check` returns `consumer_fenced`, this process no longer owns its Dispatch:\nthe Attempt was re-attached to another worker or settled without you. Stop, do\nnot send `worker_done`, and do not retry the check. An empty `check` never means\nyou were replaced; `consumer_fenced` is the only way you learn that.\nIf `send` or `ask` returns `consumer_fenced`, the command ran from another\nparty's terminal (a coordinator or another worker); run it from your own terminal.\n\n## Escalation\n\nEscalate only before completion and only when the coordinator must intervene:\n\n```text\nORCA orchestration send --from --type escalation --subject \"Blocked: \" --body \"
\" --task-id --dispatch-id \n```\n\n## Completion\n\nSend exactly one terminal report. `--body` is three sentences: what changed,\nwhat was found, and what remains. Use `--outcome failed` when the requested work\nis not complete; never hide failure in prose or silently exit.\n\nAppend `--files-modified` or `--report-path` only when applicable, using actual\npaths. Do not send documentation placeholders as metadata.\n\n```text\nORCA orchestration send --from --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded\n```\n\nAfter `worker_done`, end the dispatched turn and idle. Do not poll, close your\nown terminal, or begin unrelated work. A later direct user instruction is new\nuser-owned work and must not reuse settled lifecycle IDs; a supervised follow-up\narrives with a fresh preamble and Task block.\n" // oxfmt-ignore export const BUNDLED_SKILL_GUIDES = [ diff --git a/src/cli/command-scoped-flag-help.ts b/src/cli/command-scoped-flag-help.ts index b9238b3d6ffe..9e4a6f005278 100644 --- a/src/cli/command-scoped-flag-help.ts +++ b/src/cli/command-scoped-flag-help.ts @@ -1,3 +1,7 @@ +// Why: the shared --focus line describes terminal create's terminal session. +const FILE_OPEN_FOCUS_HELP = + "--focus Bring the user to the file (switches Orca's window to its worktree)" + /** Per-command flag help, kept out of the shared help chain it would crowd. */ const COMMAND_SCOPED_FLAG_HELP: Record> = { 'skills get': { @@ -5,6 +9,15 @@ const COMMAND_SCOPED_FLAG_HELP: Record> = { reference: '--reference Print one bundled reference by name', references: '--references List the bundled reference names for a topic' }, + 'file open': { + focus: FILE_OPEN_FOCUS_HELP + }, + 'file diff': { + focus: FILE_OPEN_FOCUS_HELP + }, + 'file open-changed': { + focus: FILE_OPEN_FOCUS_HELP + }, 'skills install': { agent: '--agent Comma-separated install targets; default is detected agents' }, diff --git a/src/cli/handlers/agent-hooks-load-boundary.test.ts b/src/cli/handlers/agent-hooks-load-boundary.test.ts index ff11e2a2d501..104ae5744da2 100644 --- a/src/cli/handlers/agent-hooks-load-boundary.test.ts +++ b/src/cli/handlers/agent-hooks-load-boundary.test.ts @@ -1,19 +1,14 @@ import { afterEach, expect, it, vi } from 'vitest' import { main } from '../index' -const { prepare } = vi.hoisted(() => ({ prepare: vi.fn() })) +const { call } = vi.hoisted(() => ({ call: vi.fn(async () => ({ result: null })) })) vi.mock('../runtime-client', () => ({ RuntimeClient: class { - async call() { - return { result: { settings: { agentStatusHooksEnabled: true } } } - } + call = call }, RuntimeClientError: Error, getDefaultUserDataPath: () => '/unused/user-data' })) -vi.mock('../../main/codex/managed-home-shell-preflight', () => ({ - prepareManagedCodexHomeBeforeShellLaunch: prepare -})) vi.mock('../../main/persistence/profile-state/profile-state-offline-settings', () => { throw new Error('Offline profile settings loaded during online preparation') }) @@ -23,20 +18,31 @@ vi.mock('../../main/persistence/profile-state/profile-state-access', () => { vi.mock('../profile-state-location', () => { throw new Error('Profile location loaded during online preparation') }) +vi.mock('../../main/codex/codex-hook-local-install', () => { + throw new Error('The Codex installer loaded in the pane CLI') +}) afterEach(() => { + call.mockClear() vi.unstubAllEnvs() vi.restoreAllMocks() process.exitCode = undefined }) -it('prepares Codex through the runtime without loading offline profile storage', async () => { - vi.stubEnv('WSL_DISTRO_NAME', '') +it('prepares a WSL pane through the runtime without loading profile storage or an installer', async () => { + vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu') const error = vi.spyOn(console, 'error').mockImplementation(() => {}) await main(['agent', 'hooks', 'prepare-codex']) expect(error).not.toHaveBeenCalled() - expect(prepare).toHaveBeenCalledWith({ - userDataPath: '/unused/user-data', - hooksEnabled: true + expect(call).toHaveBeenCalledWith('agentHooks.prepareCodexForWslPane', expect.any(Object), { + timeoutMs: 50_000 }) }) + +it('loads nothing and asks nothing for a native pane', async () => { + vi.stubEnv('WSL_DISTRO_NAME', '') + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + await main(['agent', 'hooks', 'prepare-codex']) + expect(error).not.toHaveBeenCalled() + expect(call).not.toHaveBeenCalled() +}) diff --git a/src/cli/handlers/agent-hooks-prepare-codex-isolated-home.test.ts b/src/cli/handlers/agent-hooks-prepare-codex-isolated-home.test.ts new file mode 100644 index 000000000000..01047cc79a1f --- /dev/null +++ b/src/cli/handlers/agent-hooks-prepare-codex-isolated-home.test.ts @@ -0,0 +1,122 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import type * as NodeOs from 'node:os' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { getManagedCommand } from '../../main/codex/codex-hook-definition' +import { _internals as grantInternals } from '../../main/codex/codex-hook-trust-grant' + +const { homes, call } = vi.hoisted(() => ({ + homes: { current: '' }, + call: vi.fn<() => Promise>() +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { ...actual, homedir: () => homes.current } +}) +vi.mock('electron', () => ({ + app: { getPath: () => process.env.ORCA_USER_DATA_PATH } +})) +vi.mock('../runtime-client', () => ({ + RuntimeClient: class { + call = call + }, + RuntimeClientError: Error, + getDefaultUserDataPath: () => process.env.ORCA_USER_DATA_PATH +})) + +import { main } from '../index' + +const SCRIPT_NAME = process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh' +let root: string +let appHome: string +let realHome: string +let managedHome: string + +// Why: stand in for a missing codex binary so no real app-server ever starts. +function stubMissingCodexBinary(): never { + throw Object.assign(new Error('spawn codex ENOENT'), { code: 'ENOENT' }) +} + +function listTree(dir: string): Map { + const files = new Map() + const walk = (current: string): void => { + for (const entry of readdirSync(current, { withFileTypes: true })) { + const path = join(current, entry.name) + if (entry.isDirectory()) { + walk(path) + } else { + files.set(relative(dir, path), { + bytes: readFileSync(path, 'utf-8'), + mtimeMs: statSync(path).mtimeMs + }) + } + } + } + walk(dir) + return files +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-prepare-codex-isolated-home-')) + appHome = join(root, 'app-home') + const appUserData = join(root, 'app-user-data') + realHome = join(root, 'real-home') + managedHome = join(appUserData, 'codex-runtime-home', 'home') + mkdirSync(managedHome, { recursive: true }) + // The app, running with its own HOME, installed the pane's home at spawn. + const appCommand = getManagedCommand(join(appHome, '.orca', 'agent-hooks', SCRIPT_NAME)) + writeFileSync( + join(managedHome, 'hooks.json'), + `${JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: appCommand }] }] } }, null, 2)}\n` + ) + for (const home of [appHome, realHome]) { + mkdirSync(join(home, '.codex'), { recursive: true }) + mkdirSync(join(home, '.orca', 'agent-hooks'), { recursive: true }) + writeFileSync(join(home, '.codex', 'hooks.json'), '{\n "hooks": {}\n}\n') + writeFileSync(join(home, '.codex', 'config.toml'), 'model = "user-model"\n') + writeFileSync(join(home, '.orca', 'agent-hooks', SCRIPT_NAME), '# another Orca wrote this\n') + } + vi.stubEnv('ORCA_USER_DATA_PATH', appUserData) + vi.stubEnv('CODEX_HOME', managedHome) + vi.stubEnv('ORCA_CODEX_HOME', managedHome) + vi.stubEnv('WSL_DISTRO_NAME', '') + grantInternals.setGrantSessionRunner(stubMissingCodexBinary) +}) + +afterEach(() => { + call.mockReset() + grantInternals.setGrantSessionRunner(null) + vi.unstubAllEnvs() + vi.restoreAllMocks() + process.exitCode = undefined + rmSync(root, { recursive: true, force: true }) +}) + +// Why: the pane shell can run under the real HOME (login(1)) or the app's own; +// on a native host the pane step neither writes a Codex home nor asks the app to. +it.each([ + ['the real HOME', 'real'], + ["the app's HOME", 'app'] +])('writes nothing from a pane under %s', async (_case, paneHome) => { + homes.current = paneHome === 'real' ? realHome : appHome + call.mockResolvedValue({ result: null }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const before = [realHome, appHome, managedHome].map(listTree) + + await main(['agent', 'hooks', 'prepare-codex']) + + expect(process.exitCode).toBeUndefined() + expect(call).not.toHaveBeenCalled() + expect([realHome, appHome, managedHome].map(listTree)).toEqual(before) +}) diff --git a/src/cli/handlers/agent-hooks.test.ts b/src/cli/handlers/agent-hooks.test.ts index a027364a50d1..59dcc0b90f11 100644 --- a/src/cli/handlers/agent-hooks.test.ts +++ b/src/cli/handlers/agent-hooks.test.ts @@ -34,8 +34,7 @@ const { callMock, getCliStatusMock, getDefaultUserDataPathMock, - getManagedAgentHookStatusesMock, - prepareManagedCodexHomeBeforeShellLaunchMock + getManagedAgentHookStatusesMock } = vi.hoisted(() => ({ applyAgentStatusHooksEnabledMock: vi.fn(), callMock: vi.fn(), @@ -52,8 +51,7 @@ const { }) ), getDefaultUserDataPathMock: vi.fn(), - getManagedAgentHookStatusesMock: vi.fn(), - prepareManagedCodexHomeBeforeShellLaunchMock: vi.fn() + getManagedAgentHookStatusesMock: vi.fn() })) vi.mock('../runtime-client', () => { @@ -83,10 +81,6 @@ vi.mock('../../main/agent-hooks/managed-agent-hook-controls', () => ({ getManagedAgentHookStatuses: getManagedAgentHookStatusesMock })) -vi.mock('../../main/codex/managed-home-shell-preflight', () => ({ - prepareManagedCodexHomeBeforeShellLaunch: prepareManagedCodexHomeBeforeShellLaunchMock -})) - import { main } from '../index' function readDefaultProfileState(userDataPath: string) { @@ -131,7 +125,6 @@ describe('agent hooks CLI handler', () => { callMock.mockReset() getCliStatusMock.mockClear() getManagedAgentHookStatusesMock.mockReturnValue([]) - prepareManagedCodexHomeBeforeShellLaunchMock.mockReset() process.exitCode = undefined vi.spyOn(console, 'log').mockImplementation(() => {}) vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -408,27 +401,25 @@ describe('agent hooks CLI handler', () => { expect(getCliStatusMock).not.toHaveBeenCalled() expect(callMock).not.toHaveBeenCalled() expect(applyAgentStatusHooksEnabledMock).not.toHaveBeenCalled() - expect(prepareManagedCodexHomeBeforeShellLaunchMock).not.toHaveBeenCalled() expect(readFileSync(join(userDataPath, 'orca-data.json'), 'utf8')).toBe(before) } } ) - it('prepares managed Codex trust with the current hooks setting', async () => { - const state = getDefaultPersistedState(userDataPath) - state.settings.agentStatusHooksEnabled = false - writeDataFile(userDataPath, state) - getDefaultUserDataPathMock.mockReturnValue(userDataPath) + it('does nothing for a native pane, so an old shell wrapper still exits 0', async () => { + const home = '/Users/jin/Library/Application Support/orca/codex-runtime-home/home' + vi.stubEnv('CODEX_HOME', home) + vi.stubEnv('ORCA_CODEX_HOME', home) + vi.stubEnv('WSL_DISTRO_NAME', '') await main(['agent', 'hooks', 'prepare-codex'], userDataPath) - expect(prepareManagedCodexHomeBeforeShellLaunchMock).toHaveBeenCalledWith({ - userDataPath, - hooksEnabled: false - }) + expect(callMock).not.toHaveBeenCalled() + expect(getCliStatusMock).not.toHaveBeenCalled() + expect(process.exitCode).toBeUndefined() }) - it('forwards WSL pane routing to the runtime exactly once without using the host installer', async () => { + it('forwards WSL pane routing to the runtime exactly once', async () => { const home = '/home/jin/.local/share/orca/codex-runtime-home/home' vi.stubEnv('CODEX_HOME', home) vi.stubEnv('ORCA_CODEX_HOME', home) @@ -442,7 +433,6 @@ describe('agent hooks CLI handler', () => { { codexHome: home, orcaCodexHome: home, wslDistro: 'Ubuntu-24.04' }, { timeoutMs: 50_000 } ) - expect(prepareManagedCodexHomeBeforeShellLaunchMock).not.toHaveBeenCalled() }) it('fails open when WSL runtime preparation is unavailable', async () => { @@ -452,69 +442,7 @@ describe('agent hooks CLI handler', () => { callMock.mockRejectedValue(new Error('method_not_found')) await expect(main(['agent', 'hooks', 'prepare-codex'], userDataPath)).resolves.toBeUndefined() - expect(prepareManagedCodexHomeBeforeShellLaunchMock).not.toHaveBeenCalled() - }) - - it('honors Codex-specific disablement when the runtime is unavailable', async () => { - const state = getDefaultPersistedState(userDataPath) - state.settings.disabledTuiAgents = ['codex'] - writeDataFile(userDataPath, state) - getDefaultUserDataPathMock.mockReturnValue(userDataPath) - - await main(['agent', 'hooks', 'prepare-codex'], userDataPath) - - expect(prepareManagedCodexHomeBeforeShellLaunchMock).toHaveBeenCalledWith({ - userDataPath, - hooksEnabled: false - }) - }) - - it('uses the active profile settings instead of stale legacy settings', async () => { - const profileId = 'work-profile' - const legacy = getDefaultPersistedState(userDataPath) - legacy.settings.agentStatusHooksEnabled = true - writeDataFile(userDataPath, legacy) - const profile = getDefaultPersistedState(userDataPath) - profile.settings.agentStatusHooksEnabled = false - writeDataFile(join(userDataPath, 'profiles', profileId), profile) - writeFileSync( - join(userDataPath, 'orca-profile-index.json'), - JSON.stringify({ - activeProfileId: profileId, - profiles: [{ id: profileId }] - }), - 'utf-8' - ) - getDefaultUserDataPathMock.mockReturnValue(userDataPath) - - await main(['agent', 'hooks', 'prepare-codex'], userDataPath) - - expect(prepareManagedCodexHomeBeforeShellLaunchMock).toHaveBeenCalledWith({ - userDataPath, - hooksEnabled: false - }) - }) - - it('honors live hook and Codex-specific disablement before persistence settles', async () => { - const state = getDefaultPersistedState(userDataPath) - state.settings.agentStatusHooksEnabled = true - writeDataFile(userDataPath, state) - getDefaultUserDataPathMock.mockReturnValue(userDataPath) - callMock.mockResolvedValue({ - result: { - settings: { agentStatusHooksEnabled: true, disabledTuiAgents: ['codex'] } - } - }) - - await main(['agent', 'hooks', 'prepare-codex'], userDataPath) - - expect(prepareManagedCodexHomeBeforeShellLaunchMock).toHaveBeenCalledWith({ - userDataPath, - hooksEnabled: false - }) - expect(callMock).toHaveBeenCalledExactlyOnceWith('settings.get', undefined, { - timeoutMs: 1_000 - }) + expect(process.exitCode).toBeUndefined() }) it('updates an established SQLite profile without rewriting its JSON export', async () => { diff --git a/src/cli/handlers/agent-hooks.ts b/src/cli/handlers/agent-hooks.ts index 91fd5ecde06c..1335283f39c4 100644 --- a/src/cli/handlers/agent-hooks.ts +++ b/src/cli/handlers/agent-hooks.ts @@ -10,10 +10,7 @@ import { } from '../runtime-client' import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../shared/orca-profiles' -import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection' import type { GlobalSettings } from '../../shared/global-settings-types' -import { prepareManagedCodexHomeBeforeShellLaunch } from '../../main/codex/managed-home-shell-preflight' -import { isAgentStatusHooksEnabledForAgent } from '../../shared/agent-status-hooks-setting' import type { ProfileStateOfflineLocation } from '../../main/persistence/profile-state/profile-state-offline-settings' type AgentHookCommandResult = { @@ -69,26 +66,6 @@ async function readAdmittedHookSettingsFromDisk(): Promise< ) } -async function readHookSettings( - client: RuntimeClient -): Promise> { - try { - const response = await client.call<{ - settings?: Pick - }>('settings.get', undefined, { timeoutMs: 1_000 }) - const settings = response.result.settings - if (settings && typeof settings.agentStatusHooksEnabled === 'boolean') { - return { - agentStatusHooksEnabled: settings.agentStatusHooksEnabled, - disabledTuiAgents: normalizeDisabledTuiAgents(settings.disabledTuiAgents) - } - } - } catch { - // The active profile on disk is the offline fallback. - } - return readHookSettingsFromDisk() -} - async function updateEnabledOnDisk(enabled: boolean): Promise<{ settingsPath: string settings: Pick @@ -199,26 +176,23 @@ async function setAgentHooksEnabled( export const AGENT_HOOK_HANDLERS: Record = { 'agent hooks prepare-codex': async ({ client, flags }) => { rejectRemoteHookSelection(flags) - if (process.env.WSL_DISTRO_NAME?.trim()) { - try { - await client.call( - 'agentHooks.prepareCodexForWslPane', - { - codexHome: process.env.CODEX_HOME ?? '', - orcaCodexHome: process.env.ORCA_CODEX_HOME ?? '', - wslDistro: process.env.WSL_DISTRO_NAME - }, - { timeoutMs: WSL_CODEX_PREPARE_TIMEOUT_MS } - ) - } catch { - // Best effort: old or unavailable runtimes must not block Codex launch. - } + if (!process.env.WSL_DISTRO_NAME?.trim()) { + // Why a no-op: native pane wrappers from builds up to v1.4.216 still call it; delete once no supported build's wrapper does. return } - await prepareManagedCodexHomeBeforeShellLaunch({ - userDataPath: getDefaultUserDataPath(), - hooksEnabled: isAgentStatusHooksEnabledForAgent(await readHookSettings(client), 'codex') - }) + try { + await client.call( + 'agentHooks.prepareCodexForWslPane', + { + codexHome: process.env.CODEX_HOME ?? '', + orcaCodexHome: process.env.ORCA_CODEX_HOME ?? '', + wslDistro: process.env.WSL_DISTRO_NAME + }, + { timeoutMs: WSL_CODEX_PREPARE_TIMEOUT_MS } + ) + } catch { + // Best effort: old or unavailable runtimes must not block Codex launch. + } }, 'agent hooks status': async ({ json, flags }) => { rejectRemoteHookSelection(flags) diff --git a/src/cli/handlers/file-absolute-paths.test.ts b/src/cli/handlers/file-absolute-paths.test.ts index 267d014762cf..f354e8151a6f 100644 --- a/src/cli/handlers/file-absolute-paths.test.ts +++ b/src/cli/handlers/file-absolute-paths.test.ts @@ -81,7 +81,8 @@ describe('absolute file CLI paths', () => { }) expect(callMock).toHaveBeenNthCalledWith(3, 'files.open', { worktree: 'id:repo::/root/orca/workspaces/xxx', - relativePath: 'xxx/xxx.ts' + relativePath: 'xxx/xxx.ts', + navigation: 'caller' }) }) @@ -105,7 +106,8 @@ describe('absolute file CLI paths', () => { expect(process.exitCode).toBeUndefined() expect(callMock).toHaveBeenNthCalledWith(2, 'files.open', { worktree: 'id:wt-1', - relativePath + relativePath, + navigation: 'caller' }) } @@ -267,7 +269,8 @@ describe('absolute file CLI paths', () => { expect(callMock).toHaveBeenNthCalledWith(2, 'files.openDiff', { worktree: 'id:wt-1', relativePath: 'src/App.tsx', - staged: true + staged: true, + navigation: 'caller' }) }) @@ -287,7 +290,8 @@ describe('absolute file CLI paths', () => { expect(callMock).toHaveBeenCalledTimes(1) expect(callMock).toHaveBeenCalledWith('files.open', { worktree: 'id:wt-1', - relativePath: 'src/App.tsx' + relativePath: 'src/App.tsx', + navigation: 'caller' }) }) @@ -308,7 +312,8 @@ describe('absolute file CLI paths', () => { expect(callMock).toHaveBeenNthCalledWith(2, 'files.open', { worktree: 'id:wt-1', - relativePath: absolutePath + relativePath: absolutePath, + navigation: 'caller' }) }) diff --git a/src/cli/handlers/file.test.ts b/src/cli/handlers/file.test.ts index 4e36ddbfff25..d94587125303 100644 --- a/src/cli/handlers/file.test.ts +++ b/src/cli/handlers/file.test.ts @@ -82,7 +82,8 @@ describe('orca file CLI handlers', () => { expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 }) expect(callMock).toHaveBeenNthCalledWith(2, 'files.open', { worktree: 'id:repo::/tmp/repo', - relativePath: 'src/App.tsx' + relativePath: 'src/App.tsx', + navigation: 'caller' }) expect(vi.mocked(console.log).mock.calls[0][0]).toBe('Opened src/App.tsx.') }) @@ -107,7 +108,8 @@ describe('orca file CLI handlers', () => { expect(callMock).toHaveBeenCalledWith('files.openDiff', { worktree: 'id:wt-1', relativePath: 'src/App.tsx', - staged: true + staged: true, + navigation: 'caller' }) }) @@ -127,7 +129,8 @@ describe('orca file CLI handlers', () => { expect(callMock).toHaveBeenCalledWith('files.openDiff', { worktree: 'id:wt-1', relativePath: 'assets/logo.png', - staged: false + staged: false, + navigation: 'caller' }) expect(vi.mocked(console.log).mock.calls[0][0]).toBe( 'Did not open diff for assets/logo.png: binary file.' @@ -186,17 +189,20 @@ describe('orca file CLI handlers', () => { expect(callMock).toHaveBeenNthCalledWith(3, 'files.openDiff', { worktree: 'id:repo::/tmp/repo', relativePath: 'src/App.tsx', - staged: false + staged: false, + navigation: 'caller' }) expect(callMock).toHaveBeenNthCalledWith(4, 'files.openDiff', { worktree: 'id:repo::/tmp/repo', relativePath: 'package.json', - staged: true + staged: true, + navigation: 'caller' }) expect(callMock).toHaveBeenNthCalledWith(5, 'files.openDiff', { worktree: 'id:repo::/tmp/repo', relativePath: 'docs/new.md', - staged: false + staged: false, + navigation: 'caller' }) expect(vi.mocked(console.log).mock.calls[0][0]).toBe('Opened 3 changed file targets.') }) @@ -263,7 +269,8 @@ describe('orca file CLI handlers', () => { expect(callMock).toHaveBeenNthCalledWith(2, 'files.openDiff', { worktree: 'id:wt-1', relativePath: 'src/App.tsx', - staged: true + staged: true, + navigation: 'caller' }) const output = vi.mocked(console.log).mock.calls[0][0] expect(output).toContain('Opened 1 changed file targets.') @@ -317,28 +324,163 @@ describe('orca file CLI handlers', () => { expect(callMock).toHaveBeenNthCalledWith(1, 'git.status', { worktree: 'id:wt-1' }) expect(callMock).toHaveBeenNthCalledWith(2, 'files.open', { worktree: 'id:wt-1', - relativePath: 'src/App.tsx' + relativePath: 'src/App.tsx', + navigation: 'caller' }) expect(callMock).toHaveBeenNthCalledWith(3, 'files.openDiff', { worktree: 'id:wt-1', relativePath: 'src/App.tsx', - staged: false + staged: false, + navigation: 'caller' }) expect(callMock).toHaveBeenNthCalledWith(4, 'files.openDiff', { worktree: 'id:wt-1', relativePath: 'src/App.tsx', - staged: true + staged: true, + navigation: 'caller' }) expect(callMock).toHaveBeenNthCalledWith(5, 'files.openDiff', { worktree: 'id:wt-1', relativePath: 'docs/old.md', - staged: false + staged: false, + navigation: 'caller' }) const output = vi.mocked(console.log).mock.calls[0][0] expect(output).toContain('Opened 4 changed file targets.') expect(output).toContain('docs/old.md: deleted file has no edit target') }) + it('asks the host to move its view only when --focus is passed', async () => { + const opened = { worktree: 'wt-1', relativePath: 'src/App.tsx', kind: 'text', opened: true } + queueFixtures( + callMock, + okFixture('req_open', opened), + okFixture('req_open_focus', opened), + okFixture('req_diff', opened), + okFixture('req_diff_focus', opened) + ) + + await main(['file', 'open', 'src/App.tsx', '--worktree', 'id:wt-1'], '/tmp/elsewhere') + await main( + ['file', 'open', 'src/App.tsx', '--worktree', 'id:wt-1', '--focus'], + '/tmp/elsewhere' + ) + await main(['file', 'diff', 'src/App.tsx', '--worktree', 'id:wt-1'], '/tmp/elsewhere') + await main( + ['file', 'diff', 'src/App.tsx', '--worktree', 'id:wt-1', '--focus'], + '/tmp/elsewhere' + ) + + expect(callMock).toHaveBeenNthCalledWith(1, 'files.open', { + worktree: 'id:wt-1', + relativePath: 'src/App.tsx', + navigation: 'caller' + }) + expect(callMock).toHaveBeenNthCalledWith(2, 'files.open', { + worktree: 'id:wt-1', + relativePath: 'src/App.tsx', + navigation: 'all' + }) + expect(callMock).toHaveBeenNthCalledWith(3, 'files.openDiff', { + worktree: 'id:wt-1', + relativePath: 'src/App.tsx', + staged: false, + navigation: 'caller' + }) + expect(callMock).toHaveBeenNthCalledWith(4, 'files.openDiff', { + worktree: 'id:wt-1', + relativePath: 'src/App.tsx', + staged: false, + navigation: 'all' + }) + }) + + it('sends caller to a paired remote server, and all with --focus', async () => { + const opened = { worktree: 'wt-1', relativePath: 'src/App.tsx', kind: 'text', opened: true } + queueFixtures(callMock, okFixture('req_open', opened), okFixture('req_open_focus', opened)) + const remote = ['--worktree', 'id:wt-1', '--pairing-code', 'remote-runtime'] + + await main(['file', 'open', 'src/App.tsx', ...remote], '/tmp/elsewhere') + await main(['file', 'open', 'src/App.tsx', ...remote, '--focus'], '/tmp/elsewhere') + + expect(callMock).toHaveBeenNthCalledWith(1, 'files.open', { + worktree: 'id:wt-1', + relativePath: 'src/App.tsx', + navigation: 'caller' + }) + expect(callMock).toHaveBeenNthCalledWith(2, 'files.open', { + worktree: 'id:wt-1', + relativePath: 'src/App.tsx', + navigation: 'all' + }) + }) + + it('moves the view once for open-changed --focus, on the first tab that opens', async () => { + queueFixtures( + callMock, + okFixture('req_status', { + entries: [ + { path: 'assets/logo.png', status: 'modified', area: 'unstaged' }, + { path: 'src/App.tsx', status: 'modified', area: 'unstaged' } + ], + conflictOperation: 'unknown' + }), + okFixture('req_open_binary', { + worktree: 'wt-1', + relativePath: 'assets/logo.png', + kind: 'binary', + opened: false + }), + okFixture('req_diff_1', { + worktree: 'wt-1', + relativePath: 'assets/logo.png', + kind: 'binary', + opened: true + }), + okFixture('req_open', { + worktree: 'wt-1', + relativePath: 'src/App.tsx', + kind: 'text', + opened: true + }), + okFixture('req_diff_2', { + worktree: 'wt-1', + relativePath: 'src/App.tsx', + kind: 'text', + opened: true + }) + ) + + await main( + ['file', 'open-changed', '--mode', 'both', '--worktree', 'id:wt-1', '--focus'], + '/tmp/elsewhere' + ) + + // Why: a binary edit open returns opened:false before reaching the host, so focus carries to the next open. + expect(callMock).toHaveBeenNthCalledWith(2, 'files.open', { + worktree: 'id:wt-1', + relativePath: 'assets/logo.png', + navigation: 'all' + }) + expect(callMock).toHaveBeenNthCalledWith(3, 'files.openDiff', { + worktree: 'id:wt-1', + relativePath: 'assets/logo.png', + staged: false, + navigation: 'all' + }) + expect(callMock).toHaveBeenNthCalledWith(4, 'files.open', { + worktree: 'id:wt-1', + relativePath: 'src/App.tsx', + navigation: 'caller' + }) + expect(callMock).toHaveBeenNthCalledWith(5, 'files.openDiff', { + worktree: 'id:wt-1', + relativePath: 'src/App.tsx', + staged: false, + navigation: 'caller' + }) + }) + it('requires an explicit worktree for remote file commands', async () => { const priorExitCode = process.exitCode diff --git a/src/cli/handlers/file.ts b/src/cli/handlers/file.ts index 85e91fedeb11..83be828c040c 100644 --- a/src/cli/handlers/file.ts +++ b/src/cli/handlers/file.ts @@ -1,5 +1,6 @@ import type { GitStatusEntry, GitStatusResult } from '../../shared/git-status-types' import type { RuntimeFileOpenResult, RuntimeWorktreeRecord } from '../../shared/runtime-types' +import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import { isRuntimePathAbsolute, relativePathInsideRoot } from '../../shared/cross-platform-path' import { isWslUncPath, parseWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' import type { CommandHandler, HandlerContext } from '../dispatch' @@ -116,6 +117,13 @@ function getOpenChangedMode(flags: Map): OpenChangedMo throw new RuntimeClientError('invalid_argument', 'Invalid --mode. Use edit, diff, or both.') } +// Why: the CLI has no view of its own, so 'caller' moves nothing. --focus sends 'all', but unlike +// worktree create --activate a file open moves only the host's own window (intended; paired +// clients are not navigated). Hosts treat a missing field as the legacy switch. +function getFileOpenNavigation(flags: Map): RuntimeNavigationTarget { + return flags.get('focus') === true ? 'all' : 'caller' +} + function canOpenEntryForEdit(entry: GitStatusEntry): string | null { if (entry.status === 'deleted') { return 'deleted file has no edit target' @@ -129,11 +137,13 @@ function canOpenEntryForEdit(entry: GitStatusEntry): string | null { async function openFileEdit( ctx: HandlerContext, worktree: string, - path: string + path: string, + navigation: RuntimeNavigationTarget ): Promise { const result = await ctx.client.call('files.open', { worktree, - relativePath: path + relativePath: path, + navigation }) return { path, @@ -148,12 +158,14 @@ async function openFileDiff( ctx: HandlerContext, worktree: string, path: string, - staged: boolean + staged: boolean, + navigation: RuntimeNavigationTarget ): Promise { const result = await ctx.client.call('files.openDiff', { worktree, relativePath: path, - staged + staged, + navigation }) return { path, @@ -198,7 +210,8 @@ export const FILE_HANDLERS: Record = { const relativePath = await resolveFilePath(ctx, worktree, path) const result = await ctx.client.call('files.open', { worktree, - relativePath + relativePath, + navigation: getFileOpenNavigation(ctx.flags) }) printResult(result, ctx.json, formatFileOpen) }, @@ -210,7 +223,8 @@ export const FILE_HANDLERS: Record = { const result = await ctx.client.call('files.openDiff', { worktree, relativePath, - staged + staged, + navigation: getFileOpenNavigation(ctx.flags) }) printResult(result, ctx.json, formatFileDiff) }, @@ -221,6 +235,17 @@ export const FILE_HANDLERS: Record = { const opened: FileOpenRecord[] = [] const skipped: FileOpenRecord[] = [] const openedEditPaths = new Set() + // Why: switch once — the first tab that actually opens carries --focus; later ones join the now-viewed worktree. + let navigation = getFileOpenNavigation(ctx.flags) + const openWithFocusOnce = async ( + open: () => Promise + ): Promise => { + const record = await open() + if (record.opened) { + navigation = 'caller' + } + return record + } for (const entry of status.result.entries) { if (mode === 'edit' || mode === 'both') { @@ -235,7 +260,9 @@ export const FILE_HANDLERS: Record = { }) } else if (!openedEditPaths.has(entry.path)) { openedEditPaths.add(entry.path) - const record = await openFileEdit(ctx, worktree, entry.path) + const record = await openWithFocusOnce(() => + openFileEdit(ctx, worktree, entry.path, navigation) + ) const records = record.opened ? opened : skipped records.push(record) } @@ -253,7 +280,9 @@ export const FILE_HANDLERS: Record = { reason: 'unresolved conflict may not have a single diff target' }) } else { - const record = await openFileDiff(ctx, worktree, entry.path, staged) + const record = await openWithFocusOnce(() => + openFileDiff(ctx, worktree, entry.path, staged, navigation) + ) const records = record.opened ? opened : skipped records.push(record) } diff --git a/src/cli/handlers/orchestration-migration.test.ts b/src/cli/handlers/orchestration-migration.test.ts index 863720eaab12..84c1038e3d76 100644 --- a/src/cli/handlers/orchestration-migration.test.ts +++ b/src/cli/handlers/orchestration-migration.test.ts @@ -1,6 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { ORCHESTRATION_HANDLERS } from './orchestration' +// worker_done carries a CLI-minted request id so its runtime_unavailable retries replay one mutation. +const WORKER_DONE_REQUEST = { orchestrationRequestId: expect.any(String) } + const originalPaneKey = process.env.ORCA_PANE_KEY afterEach(() => { @@ -34,20 +37,24 @@ describe('orchestration CLI migration recovery', () => { json: true } as never) - expect(call).toHaveBeenCalledWith('orchestration.send', { - from: 'term_worker', - to: undefined, - run: undefined, - subject: 'Done', - body: undefined, - type: 'worker_done', - priority: undefined, - threadId: undefined, - payload: undefined, - senderPaneKey: 'tab-worker:leaf-worker', - waitForLifecycleSettlement: true, - devMode: false - }) + expect(call).toHaveBeenCalledWith( + 'orchestration.send', + { + from: 'term_worker', + to: undefined, + run: undefined, + subject: 'Done', + body: undefined, + type: 'worker_done', + priority: undefined, + threadId: undefined, + payload: undefined, + senderPaneKey: 'tab-worker:leaf-worker', + waitForLifecycleSettlement: true, + devMode: false + }, + WORKER_DONE_REQUEST + ) expect(call).toHaveBeenCalledOnce() } ) diff --git a/src/cli/handlers/orchestration.test.ts b/src/cli/handlers/orchestration.test.ts index e285f431ad40..4ca66b603eb4 100644 --- a/src/cli/handlers/orchestration.test.ts +++ b/src/cli/handlers/orchestration.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const callMock = vi.fn() +// worker_done carries a CLI-minted request id so its runtime_unavailable retries replay one mutation. +const WORKER_DONE_REQUEST = { orchestrationRequestId: expect.any(String) } const getTerminalHandleMock = vi.hoisted(() => vi.fn()) const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE const originalPaneKey = process.env.ORCA_PANE_KEY @@ -79,24 +81,28 @@ describe('orchestration send structured payload flags', () => { ]) ) - expect(callMock).toHaveBeenCalledWith('orchestration.send', { - from: 'term_worker', - to: 'term_coord', - subject: 'done', - body: undefined, - type: 'worker_done', - priority: undefined, - threadId: undefined, - payload: JSON.stringify({ - taskId: 'task_1', - dispatchId: 'ctx_1', - outcome: 'succeeded', - filesModified: ['src/a.ts', 'src/b.ts'], - reportPath: 'reports/done.md' - }), - waitForLifecycleSettlement: true, - devMode: false - }) + expect(callMock).toHaveBeenCalledWith( + 'orchestration.send', + { + from: 'term_worker', + to: 'term_coord', + subject: 'done', + body: undefined, + type: 'worker_done', + priority: undefined, + threadId: undefined, + payload: JSON.stringify({ + taskId: 'task_1', + dispatchId: 'ctx_1', + outcome: 'succeeded', + filesModified: ['src/a.ts', 'src/b.ts'], + reportPath: 'reports/done.md' + }), + waitForLifecycleSettlement: true, + devMode: false + }, + WORKER_DONE_REQUEST + ) }) it('forwards multiline message bodies without normalization', async () => { @@ -202,18 +208,22 @@ describe('orchestration send structured payload flags', () => { ]) ) - expect(callMock).toHaveBeenCalledWith('orchestration.send', { - from: 'term_worker', - to: 'term_coord', - subject: 'done', - body: undefined, - type: 'worker_done', - priority: undefined, - threadId: undefined, - payload: JSON.stringify({ outcome: 'succeeded' }), - waitForLifecycleSettlement: true, - devMode: false - }) + expect(callMock).toHaveBeenCalledWith( + 'orchestration.send', + { + from: 'term_worker', + to: 'term_coord', + subject: 'done', + body: undefined, + type: 'worker_done', + priority: undefined, + threadId: undefined, + payload: JSON.stringify({ outcome: 'succeeded' }), + waitForLifecycleSettlement: true, + devMode: false + }, + WORKER_DONE_REQUEST + ) }) it('sends lifecycle messages from ORCA_TERMINAL_HANDLE without a liveness probe', async () => { @@ -229,18 +239,22 @@ describe('orchestration send structured payload flags', () => { ) expect(callMock).toHaveBeenCalledTimes(1) - expect(callMock).toHaveBeenCalledWith('orchestration.send', { - from: 'term_worker_env', - to: 'term_coord', - subject: 'done', - body: undefined, - type: 'worker_done', - priority: undefined, - threadId: undefined, - payload: JSON.stringify({ outcome: 'succeeded' }), - waitForLifecycleSettlement: true, - devMode: false - }) + expect(callMock).toHaveBeenCalledWith( + 'orchestration.send', + { + from: 'term_worker_env', + to: 'term_coord', + subject: 'done', + body: undefined, + type: 'worker_done', + priority: undefined, + threadId: undefined, + payload: JSON.stringify({ outcome: 'succeeded' }), + waitForLifecycleSettlement: true, + devMode: false + }, + WORKER_DONE_REQUEST + ) }) it.each(['worker_done', 'heartbeat'] as const)( @@ -265,7 +279,8 @@ describe('orchestration send structured payload flags', () => { expect(callMock).toHaveBeenCalledTimes(1) expect(callMock).toHaveBeenCalledWith( 'orchestration.send', - expect.objectContaining({ from: 'term_worker_env' }) + expect.objectContaining({ from: 'term_worker_env' }), + ...(type === 'worker_done' ? [WORKER_DONE_REQUEST] : []) ) } ) @@ -285,7 +300,8 @@ describe('orchestration send structured payload flags', () => { expect(callMock).toHaveBeenCalledWith( 'orchestration.send', - expect.objectContaining({ senderPaneKey: 'tab_worker:leaf_worker' }) + expect.objectContaining({ senderPaneKey: 'tab_worker:leaf_worker' }), + WORKER_DONE_REQUEST ) }) diff --git a/src/cli/handlers/orchestration/message-send-handler.ts b/src/cli/handlers/orchestration/message-send-handler.ts index aa9bbaff47eb..3b024de2cffa 100644 --- a/src/cli/handlers/orchestration/message-send-handler.ts +++ b/src/cli/handlers/orchestration/message-send-handler.ts @@ -12,6 +12,8 @@ import { throwNoActiveSenderTerminal } from './terminal-identity' +const WORKER_DONE_UNAVAILABLE_RETRY_MS = 120_000 + type LifecycleSendResult = | { action: 'completed' | 'failed' @@ -110,7 +112,9 @@ export const ORCHESTRATION_SEND_HANDLER: Record = { flags, 'orchestration.send', sendParams, - dispatchCapability ? { orchestrationCapability: dispatchCapability } : undefined + dispatchCapability ? { orchestrationCapability: dispatchCapability } : undefined, + // Why: a worker reports once and ends its turn, so a brief app outage must delay worker_done, not drop it. + type === 'worker_done' ? WORKER_DONE_UNAVAILABLE_RETRY_MS : 0 ) await requireWorkerDoneSettlement(client, type, sendParams.payload, result.result) if ('lifecycle' in result.result && result.result.lifecycle?.action === 'rejected') { diff --git a/src/cli/handlers/orchestration/mutation-request.test.ts b/src/cli/handlers/orchestration/mutation-request.test.ts new file mode 100644 index 000000000000..24d98e75357d --- /dev/null +++ b/src/cli/handlers/orchestration/mutation-request.test.ts @@ -0,0 +1,240 @@ +import { createServer, type Server } from 'node:net' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { RuntimeClient, RuntimeClientError } from '../../runtime-client' +import { callOrchestrationMutation } from './mutation-request' + +const RETRY_MS = 120_000 +const WORKER_DONE = { + from: 'term_worker', + subject: 'done', + type: 'worker_done', + payload: '{"taskId":"task_1","dispatchId":"ctx_1","outcome":"succeeded"}' +} + +const RuntimeRequest = z.object({ + id: z.string(), + method: z.string(), + orchestrationRequestId: z.string().optional() +}) + +const servers = new Set() +const tempDirs = new Set() + +afterEach(async () => { + vi.useRealTimers() + await Promise.all([...servers].map((server) => new Promise((resolve) => server.close(resolve)))) + servers.clear() + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }) + } + tempDirs.clear() +}) + +type CallOptions = { orchestrationRequestId?: string } + +function fakeClient(respond: (attempt: number, options?: CallOptions) => unknown): { + client: RuntimeClient + requestIds: (string | undefined)[] +} { + const requestIds: (string | undefined)[] = [] + const call = vi.fn(async (_method: string, _params: unknown, options?: CallOptions) => { + requestIds.push(options?.orchestrationRequestId) + return respond(requestIds.length, options) + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: callOrchestrationMutation only uses client.call. + return { client: { call } as unknown as RuntimeClient, requestIds } +} + +function unavailable(options?: CallOptions): RuntimeClientError { + return new RuntimeClientError( + 'runtime_unavailable', + 'Could not connect to the running Orca app.', + { + orchestrationRequestId: options?.orchestrationRequestId, + originalCommand: ['orca', 'orchestration', 'send', '--type', 'worker_done'] + } + ) +} + +describe('callOrchestrationMutation runtime_unavailable retry', () => { + it.each([ + ['a fresh', new Map(), expect.stringMatching(/^[0-9a-f-]{36}$/)], + [ + 'an explicit --retry-request', + new Map([['retry-request', '11111111-2222-4333-8444-555555555555']]), + '11111111-2222-4333-8444-555555555555' + ] + ])('retries with %s request id until the runtime answers', async (_case, flags, requestId) => { + vi.useFakeTimers() + const { client, requestIds } = fakeClient((attempt, options) => { + if (attempt < 3) { + throw unavailable(options) + } + return { ok: true, result: 'sent' } + }) + const call = callOrchestrationMutation( + client, + flags, + 'orchestration.send', + WORKER_DONE, + undefined, + RETRY_MS + ) + await vi.advanceTimersByTimeAsync(3_000) + await expect(call).resolves.toEqual({ ok: true, result: 'sent' }) + expect(requestIds).toHaveLength(3) + expect(new Set(requestIds).size).toBe(1) + expect(requestIds[0]).toEqual(requestId) + }) + + it('does not retry an error other than runtime_unavailable', async () => { + const { client, requestIds } = fakeClient(() => { + throw new RuntimeClientError('runtime_timeout', 'Timed out.') + }) + await expect( + callOrchestrationMutation( + client, + new Map(), + 'orchestration.send', + WORKER_DONE, + undefined, + RETRY_MS + ) + ).rejects.toMatchObject({ code: 'runtime_timeout' }) + expect(requestIds).toHaveLength(1) + }) + + it('does not retry mutations that did not opt in', async () => { + const { client, requestIds } = fakeClient((_attempt, options) => { + throw unavailable(options) + }) + await expect( + callOrchestrationMutation(client, new Map(), 'orchestration.send', WORKER_DONE) + ).rejects.toMatchObject({ code: 'runtime_unavailable' }) + expect(requestIds).toEqual([undefined]) + }) + + it('keeps the recovery command when the last attempt fails before its request id is attached', async () => { + vi.useFakeTimers() + const { client, requestIds } = fakeClient((attempt, options) => { + throw attempt === 1 + ? unavailable(options) + : new RuntimeClientError('runtime_unavailable', 'No runtime metadata.') + }) + const settled = callOrchestrationMutation( + client, + new Map(), + 'orchestration.send', + WORKER_DONE, + undefined, + RETRY_MS + ).catch((error: unknown) => error) + await vi.advanceTimersByTimeAsync(RETRY_MS + 30_000) + expect(await settled).toMatchObject({ + data: { recovery: { orchestrationRequestId: requestIds[0] } } + }) + }) + + it('gives up after about two minutes and prints the recovery command with the same id', async () => { + vi.useFakeTimers() + const { client, requestIds } = fakeClient((_attempt, options) => { + throw unavailable(options) + }) + const call = callOrchestrationMutation( + client, + new Map(), + 'orchestration.send', + WORKER_DONE, + undefined, + RETRY_MS + ) + const settled = call.catch((error: unknown) => error) + await vi.advanceTimersByTimeAsync(RETRY_MS + 30_000) + const error = await settled + expect(requestIds.length).toBeGreaterThan(5) + expect(new Set(requestIds).size).toBe(1) + expect(error).toMatchObject({ + code: 'runtime_unavailable', + data: { + recovery: { + retryCommand: [ + 'orca', + 'orchestration', + 'send', + '--type', + 'worker_done', + '--retry-request', + requestIds[0] + ] + } + } + }) + }) + + // Why: the fake runtime listens on a Unix socket path, which Windows does not accept. + it.skipIf(process.platform === 'win32')( + 'reaches a runtime that comes back after the first attempt found it down', + async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-worker-done-retry-')) + tempDirs.add(userDataPath) + const endpoint = join(userDataPath, 'runtime.sock') + const sendRequestIds: string[] = [] + const server = createServer((socket) => { + socket.setEncoding('utf8') + socket.once('data', (line: string) => { + const request = RuntimeRequest.parse(JSON.parse(line.trim())) + if (request.method === 'orchestration.send') { + sendRequestIds.push(String(request.orchestrationRequestId)) + if (sendRequestIds.length === 1) { + // A runtime that drops the connection mid-request leaves the outcome unknown. + socket.destroy() + return + } + } + socket.end( + `${JSON.stringify({ + id: request.id, + ok: true, + result: + request.method === 'status.get' + ? { capabilities: [ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY] } + : { message: { id: 'msg_1' } }, + _meta: { runtimeId: 'runtime-1' } + })}\n` + ) + }) + }) + servers.add(server) + const client = new RuntimeClient(userDataPath, 5_000, null, null, 'orca') + const call = callOrchestrationMutation( + client, + new Map(), + 'orchestration.send', + WORKER_DONE, + undefined, + RETRY_MS + ) + // The runtime is down for the first attempt: no metadata, so even the contract probe fails. + await new Promise((resolve) => server.listen(endpoint, resolve)) + writeFileSync( + join(userDataPath, 'orca-runtime.json'), + JSON.stringify({ + runtimeId: 'runtime-1', + pid: 1, + transports: [{ kind: 'unix', endpoint }], + authToken: 'token', + startedAt: 1 + }) + ) + await expect(call).resolves.toMatchObject({ result: { message: { id: 'msg_1' } } }) + expect(sendRequestIds).toHaveLength(2) + expect(sendRequestIds[1]).toBe(sendRequestIds[0]) + }, + 15_000 + ) +}) diff --git a/src/cli/handlers/orchestration/mutation-request.ts b/src/cli/handlers/orchestration/mutation-request.ts index c262a81b86c1..2e9836c20efa 100644 --- a/src/cli/handlers/orchestration/mutation-request.ts +++ b/src/cli/handlers/orchestration/mutation-request.ts @@ -1,21 +1,56 @@ -import type { RuntimeClient } from '../../runtime-client' +import { randomUUID } from 'node:crypto' +import { RuntimeClientError, type RuntimeClient } from '../../runtime-client' import { readRetryRequestFlag } from '../../retry-request-flag' import { orchestrationMutationRecoveryError } from '../../orchestration-mutation-recovery' -export function callOrchestrationMutation( +const MAX_UNAVAILABLE_RETRY_DELAY_MS = 15_000 + +export async function callOrchestrationMutation( client: RuntimeClient, flags: Map, method: string, params: unknown, - options?: { timeoutMs?: number; orchestrationCapability?: string } + options?: { timeoutMs?: number; orchestrationCapability?: string }, + unavailableRetryMs = 0 ) { - const requestId = readRetryRequestFlag(flags) - const result = requestId - ? client.call(method, params, { ...options, orchestrationRequestId: requestId }) - : options - ? client.call(method, params, options) - : client.call(method, params) - return result.catch((error) => { - throw orchestrationMutationRecoveryError(error) - }) + // Why: every retry reuses one request id, so the host replays instead of applying the mutation twice. + const requestId = + readRetryRequestFlag(flags) ?? (unavailableRetryMs > 0 ? randomUUID() : undefined) + const deadline = Date.now() + unavailableRetryMs + let sentError: RuntimeClientError | undefined + for (let delayMs = 1_000; ; delayMs = Math.min(delayMs * 2, MAX_UNAVAILABLE_RETRY_DELAY_MS)) { + try { + return requestId + ? await client.call(method, params, { + ...options, + orchestrationRequestId: requestId + }) + : options + ? await client.call(method, params, options) + : await client.call(method, params) + } catch (error) { + const unavailable = + error instanceof RuntimeClientError && error.code === 'runtime_unavailable' + if (carriesRequestId(error)) { + sentError = error + } + if (!unavailable || Date.now() + delayMs > deadline) { + // Why: a later attempt can fail before its request id is attached, though an earlier one may have landed. + throw orchestrationMutationRecoveryError( + carriesRequestId(error) ? error : (sentError ?? error) + ) + } + await new Promise((resolve) => setTimeout(resolve, delayMs)) + } + } +} + +function carriesRequestId(error: unknown): error is RuntimeClientError { + const data: unknown = error instanceof RuntimeClientError ? error.data : undefined + return ( + typeof data === 'object' && + data !== null && + 'orchestrationRequestId' in data && + typeof data.orchestrationRequestId === 'string' + ) } diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts index 3541beef20b5..f74f6df79337 100644 --- a/src/cli/handlers/worktree.ts +++ b/src/cli/handlers/worktree.ts @@ -304,6 +304,10 @@ export const WORKTREE_HANDLERS: Record = { }) printHookWarning(result.result, json) printPreservedBranchWarning(result.result, json) - printResult(result, json, (value) => `removed: ${value.removed}`) + printResult(result, json, (value) => + value.removing + ? `removed: ${value.removed}\nOrca is still deleting the checkout in the background.` + : `removed: ${value.removed}` + ) } } diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index bed4b9f8ab99..2a6c014b984e 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -120,6 +120,20 @@ describe('command aliases dispatch to the canonical handler', () => { ) }) + it('says the checkout is still being deleted when the host accepted a background removal', async () => { + queueFixtures( + callMock, + okFixture('req_show', { worktree: { hostId: 'local' } }), + okFixture('req', { removed: true, removing: true }) + ) + + await main(['worktree', 'rm', '--worktree', 'id:wt-1'], '/tmp/repo') + + expect(logSpy).toHaveBeenCalledWith( + 'removed: true\nOrca is still deleting the checkout in the background.' + ) + }) + it('fails closed when worktree removal cannot resolve a host', async () => { queueFixtures(callMock, okFixture('req_show', { worktree: { id: 'wt-1' } })) const priorExitCode = process.exitCode diff --git a/src/cli/orchestration-mutation-recovery.test.ts b/src/cli/orchestration-mutation-recovery.test.ts index c20525e15e12..e79b72d74d21 100644 --- a/src/cli/orchestration-mutation-recovery.test.ts +++ b/src/cli/orchestration-mutation-recovery.test.ts @@ -46,7 +46,7 @@ describe('orchestration mutation recovery', () => { ) expect((result.data as { nextSteps?: string[] }).nextSteps).toEqual([ 'Run orca orchestration worker-show --dispatch dispatch_1 --json before retrying.', - 'After inspecting the Dispatch, if keyed recovery is still needed, run orca orchestration worker-start --task task_1 --retry-request request_1. --retry-request reuses the same operation identity so Orca can replay, join, or safely recover it without starting a separate duplicate.' + 'After inspecting the Dispatch, if keyed recovery is still needed, run orca orchestration worker-start --task task_1 --retry-request request_1 from this same terminal. --retry-request reuses the same operation identity so Orca can replay, join, or safely recover it without starting a separate duplicate.' ]) }) @@ -122,7 +122,7 @@ describe('orchestration mutation recovery', () => { expect((result.data as { nextSteps?: string[] }).nextSteps).toEqual([ 'Run orca-dev orchestration worker-show --dispatch dispatch_3 --json before retrying.', - "After inspecting the Dispatch, if keyed recovery is still needed, run orca-dev orchestration worker-start --task 'task 3' --comment 'literal $(do-not-run)' --retry-request request_3. --retry-request reuses the same operation identity so Orca can replay, join, or safely recover it without starting a separate duplicate." + "After inspecting the Dispatch, if keyed recovery is still needed, run orca-dev orchestration worker-start --task 'task 3' --comment 'literal $(do-not-run)' --retry-request request_3 from this same terminal. --retry-request reuses the same operation identity so Orca can replay, join, or safely recover it without starting a separate duplicate." ]) expect(result.message).toContain("'literal $(do-not-run)'") }) diff --git a/src/cli/orchestration-mutation-recovery.ts b/src/cli/orchestration-mutation-recovery.ts index 0ac89165ce03..31439fae7e4b 100644 --- a/src/cli/orchestration-mutation-recovery.ts +++ b/src/cli/orchestration-mutation-recovery.ts @@ -44,8 +44,8 @@ export function orchestrationMutationRecoveryError(error: unknown): unknown { } const retryStep = retryCommand ? dispatchId - ? `After inspecting the Dispatch, if keyed recovery is still needed, run ${renderCommand(retryCommand)}. --retry-request reuses the same operation identity so Orca can replay, join, or safely recover it without starting a separate duplicate.` - : `If request-show reports completed or pending, run ${renderCommand(retryCommand)}. --retry-request reuses the same operation identity so Orca can replay, join, or safely recover it without starting a separate duplicate. If request-show reports absent, inspect the affected state before deciding whether to retry; absence does not prove a retry is safe.` + ? `After inspecting the Dispatch, if keyed recovery is still needed, run ${renderCommand(retryCommand)} from this same terminal. --retry-request reuses the same operation identity so Orca can replay, join, or safely recover it without starting a separate duplicate.` + : `If request-show reports completed or pending, run ${renderCommand(retryCommand)} from this same terminal. --retry-request reuses the same operation identity so Orca can replay, join, or safely recover it without starting a separate duplicate. If request-show reports absent, inspect the affected state before deciding whether to retry; absence does not prove a retry is safe.` : 'Recovery is blocked until the exact original command is available; no retry command was emitted.' const nextSteps = [`Run ${renderCommand(queryCommand)} before retrying.`, retryStep] const message = [ diff --git a/src/cli/root-help-text-primary.ts b/src/cli/root-help-text-primary.ts index 2d4561f30984..6d4b9e6c1898 100644 --- a/src/cli/root-help-text-primary.ts +++ b/src/cli/root-help-text-primary.ts @@ -76,9 +76,9 @@ export const ROOT_HELP_TEXT_PRIMARY = [ ' worktree ps Show a compact orchestration summary across worktrees', '', 'Files:', - ' file open Open a workspace file in the Orca editor', - ' file diff Open a workspace file diff in the Orca editor', - ' file open-changed Open all git-changed files for a workspace', + ' file open Open a file as a tab, view stays put (--focus brings you to it)', + ' file diff Open a file diff as a tab, view stays put (--focus brings you to it)', + ' file open-changed Open changed files as tabs, view stays put (--focus brings you there)', '', 'Terminals:', ' terminal list List live Orca-managed terminals', diff --git a/src/cli/root-help-text-secondary.ts b/src/cli/root-help-text-secondary.ts index 8602e35c49ea..34eeb71cf0fe 100644 --- a/src/cli/root-help-text-secondary.ts +++ b/src/cli/root-help-text-secondary.ts @@ -56,9 +56,9 @@ export const ROOT_HELP_TEXT_SECONDARY = [ ' orca worktree set --worktree [--display-name ] [--issue ] [--linear-issue ] [--comment ] [--workspace-status ] [--parent-worktree |--no-parent] [--json]', ' orca worktree rm --worktree [--force] [--run-hooks] [--allow-failed-archive-hook] [--json]', ' orca worktree ps [--limit ] [--json]', - ' orca file open [--worktree ] [--json]', - ' orca file diff [--staged] [--worktree ] [--json]', - ' orca file open-changed [--mode edit|diff|both] [--worktree ] [--json]', + ' orca file open [--worktree ] [--focus] [--json]', + ' orca file diff [--staged] [--worktree ] [--focus] [--json]', + ' orca file open-changed [--mode edit|diff|both] [--worktree ] [--focus] [--json]', ' orca terminal list [--worktree ] [--limit ] [--include-visual-layouts] [--json]', ' orca terminal show [--terminal ] [--json]', ' orca terminal read [--terminal ] [--cursor ] [--limit ] [--json]', diff --git a/src/cli/runtime-client-deferral.test.ts b/src/cli/runtime-client-deferral.test.ts index 7dc29179065d..c5cadcfcec71 100644 --- a/src/cli/runtime-client-deferral.test.ts +++ b/src/cli/runtime-client-deferral.test.ts @@ -40,8 +40,7 @@ vi.mock('./runtime/environments', async (importOriginal) => { // null-vs-undefined coverage the rest of the table cannot. vi.mock('../main/agent-hooks/managed-agent-hook-controls', () => ({ applyAgentStatusHooksEnabled: applyAgentStatusHooksEnabledMock, - getManagedAgentHookStatuses: vi.fn(() => []), - prepareManagedCodexHomeBeforeShellLaunch: vi.fn(async () => {}) + getManagedAgentHookStatuses: vi.fn(() => []) })) vi.mock('./runtime-client', () => { diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index 68a099ff2f22..5efe54c7ba35 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -245,7 +245,13 @@ export class RuntimeClient { private async ensureOrchestrationContractCompatible(timeoutMs: number): Promise { if (!this.orchestrationContractCheck) { - this.orchestrationContractCheck = this.checkOrchestrationContractCompatibility(timeoutMs) + this.orchestrationContractCheck = this.checkOrchestrationContractCompatibility( + timeoutMs + ).catch((error: unknown) => { + // Why: a failed probe must not be cached, or a retry after a brief outage never reaches the app. + this.orchestrationContractCheck = null + throw error + }) } await this.orchestrationContractCheck } diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index e6fc34699ff5..59a4102471ba 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -32,6 +32,7 @@ import { WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from '../../shared/agent-session-background-task-child-views-capability' vi.mock('./launch', () => ({ launchOrcaApp: vi.fn() @@ -75,6 +76,7 @@ describe('CLI remote WebSocket transport', () => { clientCapabilities: [ AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, diff --git a/src/cli/specs/agent-hooks.ts b/src/cli/specs/agent-hooks.ts index d2b33a07f756..1ce8d75f6b46 100644 --- a/src/cli/specs/agent-hooks.ts +++ b/src/cli/specs/agent-hooks.ts @@ -4,7 +4,7 @@ import { GLOBAL_FLAGS } from '../args' export const AGENT_HOOK_COMMAND_SPECS: CommandSpec[] = [ { path: ['agent', 'hooks', 'prepare-codex'], - summary: 'Repair Orca-managed Codex hook trust before a shell launch', + summary: "Prepare a WSL pane's Orca-managed Codex home before a shell launch", usage: 'orca agent hooks prepare-codex', allowedFlags: [...GLOBAL_FLAGS] }, diff --git a/src/cli/specs/file.test.ts b/src/cli/specs/file.test.ts new file mode 100644 index 000000000000..9ef94f206149 --- /dev/null +++ b/src/cli/specs/file.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { findCommandSpec } from '../args' +import { formatCommandHelp } from '../help' +import { COMMAND_SPECS } from './index' + +function optionLine(path: string[], flag: string): string | undefined { + const spec = findCommandSpec(COMMAND_SPECS, path) + if (!spec) { + throw new Error(`no spec for ${path.join(' ')}`) + } + return formatCommandHelp(spec) + .split('\n') + .find((line) => line.trimStart().startsWith(`--${flag} `)) +} + +describe('orca file command help', () => { + it.each([['open'], ['diff'], ['open-changed']])( + 'file %s describes --focus as bringing the user to the file', + (command) => { + expect(optionLine(['file', command], 'focus')).toBe( + " --focus Bring the user to the file (switches Orca's window to its worktree)" + ) + } + ) + + it('leaves terminal create --focus describing its terminal session', () => { + expect(optionLine(['terminal', 'create'], 'focus')).toBe( + ' --focus Reveal the created terminal session in Orca' + ) + }) +}) diff --git a/src/cli/specs/file.ts b/src/cli/specs/file.ts index 863a4fdcf652..572671c49fbc 100644 --- a/src/cli/specs/file.ts +++ b/src/cli/specs/file.ts @@ -4,25 +4,29 @@ import { GLOBAL_FLAGS } from '../args' export const FILE_COMMAND_SPECS: CommandSpec[] = [ { path: ['file', 'open'], - summary: 'Open a workspace file in the Orca editor', - usage: 'orca file open [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'path', 'worktree'], + summary: + 'Open a file as a tab in its worktree without changing your view; --focus brings you to it', + usage: 'orca file open [--worktree ] [--focus] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'path', 'worktree', 'focus'], positionalArgs: ['path'], notes: [ + 'The file opens as a tab in that worktree without changing what you are looking at, even in the worktree on screen. --focus brings you to it; agents should pass --focus only when the user asked to see the file.', 'The path may be relative to the selected worktree or an absolute path inside that worktree. When --worktree is omitted, local CLI calls infer the current Orca worktree from cwd.' ], examples: [ 'orca file open src/App.tsx', - 'orca file open --path docs/readme.md --worktree active' + 'orca file open --path docs/readme.md --worktree active --focus' ] }, { path: ['file', 'diff'], - summary: 'Open a workspace file diff in the Orca editor', - usage: 'orca file diff [--staged] [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'path', 'staged', 'worktree'], + summary: + 'Open a file diff as a tab in its worktree without changing your view; --focus brings you to it', + usage: 'orca file diff [--staged] [--worktree ] [--focus] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'path', 'staged', 'worktree', 'focus'], positionalArgs: ['path'], notes: [ + 'The file opens as a tab in that worktree without changing what you are looking at, even in the worktree on screen. --focus brings you to it; agents should pass --focus only when the user asked to see the file.', 'Diffs default to unstaged changes. Pass --staged to open the staged source-control diff.', 'The path may be relative to the selected worktree or an absolute path inside that worktree.' ], @@ -33,10 +37,13 @@ export const FILE_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['file', 'open-changed'], - summary: 'Open all git-changed files for a workspace', - usage: 'orca file open-changed [--mode edit|diff|both] [--worktree ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'mode', 'worktree'], + summary: + 'Open all git-changed files as tabs without changing your view; --focus brings you to them', + usage: + 'orca file open-changed [--mode edit|diff|both] [--worktree ] [--focus] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'mode', 'worktree', 'focus'], notes: [ + 'The files open as tabs in that worktree without changing what you are looking at, even in the worktree on screen. --focus brings you to them; agents should pass --focus only when the user asked to see the files.', 'For v1, changed files come from git status for the selected worktree.', 'The default mode is diff. Edit mode skips deleted files because there is no file to open.' ], diff --git a/src/cli/specs/orchestration.ts b/src/cli/specs/orchestration.ts index 8c4418f38ba5..90cb4971c4ce 100644 --- a/src/cli/specs/orchestration.ts +++ b/src/cli/specs/orchestration.ts @@ -167,10 +167,13 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ path: ['orchestration', 'task-update'], summary: 'Update a task status', usage: - 'orca orchestration task-update --id --status [--result ] [--run ] [--from ] [--retry-request ] [--json]', + 'orca orchestration task-update --id --status [--result ] [--run ] [--from ] [--retry-request ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'id', 'status', 'result', 'run', 'from', 'retry-request'], identityFlagRoles: { from: 'caller' }, - notes: ['Valid --status values: pending, ready, dispatched, completed, failed, blocked.'] + notes: [ + 'Valid --status values: pending, ready, dispatched, completed, failed, blocked.', + 'To cancel a Task, stop or abandon its worker, then set --status failed --result cancelled; a later worker-start --retry-of reopens it.' + ] }, ...ORCHESTRATION_WORKER_COMMAND_SPECS, { diff --git a/src/main/agent-hooks/hook-post-command.ts b/src/main/agent-hooks/hook-post-command.ts index 1595e681899e..8cb8e2071cb0 100644 --- a/src/main/agent-hooks/hook-post-command.ts +++ b/src/main/agent-hooks/hook-post-command.ts @@ -18,6 +18,7 @@ export function buildPosixAgentHookPostCommand( ` ${indent}-H "X-Orca-Agent-Hook-Token: \${ORCA_AGENT_HOOK_TOKEN}" \\`, ` ${indent}-H "X-Orca-Agent-Hook-Meta-Encoding: base64" \\`, ` ${indent}-H "X-Orca-Agent-Hook-Meta: \${orca_hook_metadata}" \\`, + ` ${indent}-H "X-Orca-Agent-Process: \${orca_agent_process:-}" \\`, ` ${indent}--data-binary @-`, 'else', ` printf '%s' "$payload" | ${curlCommand} -sS -X POST "http://127.0.0.1:\${ORCA_AGENT_HOOK_PORT}/hook/${source}" \\`, @@ -31,6 +32,7 @@ export function buildPosixAgentHookPostCommand( ` ${indent}--data-urlencode "worktreeId=\${ORCA_WORKTREE_ID}" \\`, ` ${indent}--data-urlencode "env=\${ORCA_AGENT_HOOK_ENV}" \\`, ` ${indent}--data-urlencode "version=\${ORCA_AGENT_HOOK_VERSION}" \\`, + ` ${indent}--data-urlencode "agentProcess=\${orca_agent_process:-}" \\`, ` ${indent}--data-urlencode "payload@-"`, 'fi' ] diff --git a/src/main/agent-hooks/hook-process-capture.test.ts b/src/main/agent-hooks/hook-process-capture.test.ts new file mode 100644 index 000000000000..6cf965ae261f --- /dev/null +++ b/src/main/agent-hooks/hook-process-capture.test.ts @@ -0,0 +1,50 @@ +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { wrapRuntimeHomeHookCommand } from './runtime-home-hook-command' +import { buildHookProcessCapture } from './hook-process-capture' +import { readAgentProcessIdentity } from '../../shared/agent-process-presence' +import { probeAgentProcessPresence } from '../../shared/agent-process-presence-probe' + +describe('Claude outer hook process capture', () => { + it.skipIf(process.platform === 'win32').each([undefined, 'Asia/Tokyo'])( + 'captures the parent before the managed script shell, under an empty environment (TZ=%s)', + async (timeZone) => { + const home = await mkdtemp(join(tmpdir(), 'orca-presence-hook-')) + try { + const dir = join(home, '.orca', 'agent-hooks') + await mkdir(dir, { recursive: true }) + await writeFile( + join(dir, 'claude-hook.sh'), + ['#!/bin/sh', ...buildHookProcessCapture(), 'printf "%s" "$orca_agent_process"'].join( + '\n' + ), + { mode: 0o700 } + ) + const result = await runProcess({ + program: '/usr/bin/env', + args: [ + '-i', + `HOME=${home}`, + 'PATH=/usr/bin:/bin', + 'ORCA_PANE_KEY=presence-test-pane', + // Why: the agent's shell zone may differ from Orca's; the identity must not. + ...(timeZone ? [`TZ=${timeZone}`] : []), + '/bin/sh', + '-c', + wrapRuntimeHomeHookCommand('claude-hook') + ], + timeoutMs: 5000 + }) + expect(result.code).toBe(0) + const identity = readAgentProcessIdentity(result.stdout) + expect(identity?.pid).toBe(process.pid) + expect(await probeAgentProcessPresence(identity)).toBe('live') + } finally { + await rm(home, { recursive: true, force: true }) + } + } + ) +}) diff --git a/src/main/agent-hooks/hook-process-capture.ts b/src/main/agent-hooks/hook-process-capture.ts new file mode 100644 index 000000000000..0e68c51fed1b --- /dev/null +++ b/src/main/agent-hooks/hook-process-capture.ts @@ -0,0 +1,24 @@ +/** Capture before Orca inserts its managed-script shell. */ +export const CLAUDE_HOOK_PARENT_CAPTURE = + 'ORCA_HOOK_AGENT_PID="${PPID:-}"; export ORCA_HOOK_AGENT_PID; ' + +export function buildHookProcessCapture(): string[] { + return [ + 'orca_agent_process=', + // Why: skip the process reads for Claude sessions outside an Orca pane. + '[ -z "${ORCA_PANE_KEY:-}" ] || case "${ORCA_HOOK_AGENT_PID:-}" in ""|*[!0-9]*) ;; *)', + ' if [ -r "/proc/$ORCA_HOOK_AGENT_PID/stat" ]; then', + ' orca_agent_stat=$(cat "/proc/$ORCA_HOOK_AGENT_PID/stat" 2>/dev/null) || orca_agent_stat=', + ' orca_agent_boot=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null) || orca_agent_boot=', + ' orca_agent_fields=${orca_agent_stat##*) }', + ' orca_agent_start=$(printf "%s" "$orca_agent_fields" | awk \'{print $20}\')', + ' case "$orca_agent_start" in ""|*[!0-9]*) ;; *)', + ' [ -z "$orca_agent_boot" ] || orca_agent_process=$(printf \'{"pid":%s,"platform":"linux","startTime":"%s:%s"}\' "$ORCA_HOOK_AGENT_PID" "$orca_agent_boot" "$orca_agent_start") ;; esac', + ' elif [ "$(uname -s 2>/dev/null)" = Darwin ]; then', + // Why: lstart is local time; the host probe pins the same zone so the strings compare. + ' orca_agent_start=$(TZ=UTC0 LC_ALL=C /bin/ps -p "$ORCA_HOOK_AGENT_PID" -o lstart= 2>/dev/null | sed \'s/^ *//;s/ *$//\')', + ' [ -z "$orca_agent_start" ] || orca_agent_process=$(printf \'{"pid":%s,"platform":"darwin","startTime":"%s"}\' "$ORCA_HOOK_AGENT_PID" "$orca_agent_start")', + ' fi ;;', + 'esac' + ] +} diff --git a/src/main/agent-hooks/hook-stdin-contract.ts b/src/main/agent-hooks/hook-stdin-contract.ts index eeec395b5785..3d4fe2392c5d 100644 --- a/src/main/agent-hooks/hook-stdin-contract.ts +++ b/src/main/agent-hooks/hook-stdin-contract.ts @@ -128,11 +128,11 @@ export function buildPosixHookSpoolLines(source: string, eventNameVar?: string): const eventArg = eventNameVar ? ` "$(spool_json_escape "\${${eventNameVar}:-}")"` : '' const spoolRecordLine = " { printf '\\n{".concat( eventFormat, - '"paneKey":"%s","tabId":"%s","worktreeId":"%s","env":"%s","version":"%s","launchToken":"%s","source":"%s","receivedAt":%s,"payload":%s}\\n\'', + '"paneKey":"%s","tabId":"%s","worktreeId":"%s","env":"%s","version":"%s","launchToken":"%s","source":"%s","receivedAt":%s,"agentProcess":"%s","payload":%s}\\n\'', eventArg, ' "$(spool_json_escape "${ORCA_PANE_KEY:-}")" "$(spool_json_escape "${ORCA_TAB_ID:-}")" "$(spool_json_escape "${ORCA_WORKTREE_ID:-}")" "$(spool_json_escape "${ORCA_AGENT_HOOK_ENV:-}")" "$(spool_json_escape "${ORCA_AGENT_HOOK_VERSION:-}")" "$(spool_json_escape "${ORCA_AGENT_LAUNCH_TOKEN:-}")" "$(spool_json_escape "', source, - '")" "$spool_now" "$payload"; } >> "$spool_file" 2>/dev/null || :' + '")" "$spool_now" "$(spool_json_escape "${orca_agent_process:-}")" "$payload"; } >> "$spool_file" 2>/dev/null || :' ) return [ 'spool_hook_event() {', diff --git a/src/main/agent-hooks/managed-agent-hook-controls.ts b/src/main/agent-hooks/managed-agent-hook-controls.ts index a667e056669c..d1b64cb3e598 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.ts @@ -22,7 +22,6 @@ import { } from './managed-agent-hook-registry' export { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-registry' -export { prepareManagedCodexHomeBeforeShellLaunch } from '../codex/managed-home-shell-preflight' export { isAgentStatusHooksEnabled, isAgentStatusHooksEnabledForAgent diff --git a/src/main/agent-hooks/managed-hook-command-contract.test.ts b/src/main/agent-hooks/managed-hook-command-contract.test.ts index 04eb500c2182..247c50e7f903 100644 --- a/src/main/agent-hooks/managed-hook-command-contract.test.ts +++ b/src/main/agent-hooks/managed-hook-command-contract.test.ts @@ -233,9 +233,8 @@ describe('managed hook command contract', () => { // Native PowerShell hooks evaluate these variables without Grok's dollar-byte scanner. const scannedCommand = platform === 'win32' && - ((agent === 'codex' && command.startsWith('if (Test-Path')) || - ((agent === 'qoder' || agent === 'codebuddy') && - command.startsWith('$scriptPath = Join-Path'))) + (agent === 'qoder' || agent === 'codebuddy') && + command.startsWith('$scriptPath = Join-Path') ? command .replaceAll('$LASTEXITCODE', '') .replaceAll('$env:', '') diff --git a/src/main/agent-hooks/posix-hook-command.ts b/src/main/agent-hooks/posix-hook-command.ts index 23d8f1acba8e..8a9f6dc7bf41 100644 --- a/src/main/agent-hooks/posix-hook-command.ts +++ b/src/main/agent-hooks/posix-hook-command.ts @@ -1,6 +1,6 @@ import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract' -function quotePosixShellString(value: string): string { +export function quotePosixShellString(value: string): string { return `'${value.replaceAll("'", "'\\''")}'` } diff --git a/src/main/agent-hooks/runtime-home-hook-command.ts b/src/main/agent-hooks/runtime-home-hook-command.ts index e56fc603b436..4b59f7b53ed9 100644 --- a/src/main/agent-hooks/runtime-home-hook-command.ts +++ b/src/main/agent-hooks/runtime-home-hook-command.ts @@ -1,3 +1,4 @@ +import { CLAUDE_HOOK_PARENT_CAPTURE } from './hook-process-capture' import { POSIX_HOOK_STDIN_DRAIN_COMMAND, WINDOWS_GIT_BASH_HOOK_ENVIRONMENT_GUARD, @@ -46,7 +47,7 @@ export function wrapRuntimeHomeHookCommand( const powershellInvocation = `${powershell} ${WINDOWS_POWERSHELL_HOOK_SWITCHES} -EncodedCommand ${encodedCommand}` const encodedWindowsBranch = `if [ -f ${powershell} ]; then ${powershellInvocation}; else ${windowsMissingScriptFallback}; fi` const windowsBranch = `if [ -f ${windowsScript} ]; then case "\${HOME-}" in ${WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE}) ${encodedWindowsBranch} ;; *) ${windowsScript} ;; esac; else ${windowsMissingScriptFallback}; fi` - const posixBranch = `if [ -f ${posixScript} ] && [ -r ${posixScript} ] && [ -x ${posixScript} ]; then /bin/sh ${posixScript}; else ${posixMissingScriptFallback}; fi` + const posixBranch = `if [ -f ${posixScript} ] && [ -r ${posixScript} ] && [ -x ${posixScript} ]; then ${scriptBaseName === 'claude-hook' || scriptBaseName === 'openclaude-hook' ? CLAUDE_HOOK_PARENT_CAPTURE : ''}/bin/sh ${posixScript}; else ${posixMissingScriptFallback}; fi` // Why: OSTYPE is shell-owned, so platform selection adds no process to every hook invocation. return `if [ -z "\${HOME-}" ]; then ${missingScriptFallback}; else case "\${OSTYPE-}" in msys*|cygwin*|win32*) ${windowsBranch} ;; *) ${posixBranch} ;; esac; fi` } diff --git a/src/main/agent-hooks/server-agent-presence.test.ts b/src/main/agent-hooks/server-agent-presence.test.ts new file mode 100644 index 000000000000..cd8da3aecb4c --- /dev/null +++ b/src/main/agent-hooks/server-agent-presence.test.ts @@ -0,0 +1,346 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from './server' +import { buildBody, PANE, postHookEvent } from './server.test-fixtures' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) })) + +const probe = vi.hoisted(() => + vi.fn(async (): Promise<'live' | 'unverifiable' | 'exited'> => 'unverifiable') +) +vi.mock('../../shared/agent-process-presence-probe', () => ({ probeAgentProcessPresence: probe })) +const servers: AgentHookServer[] = [] +afterEach(() => { + for (const server of servers.splice(0)) { + server.stop() + } + probe.mockReset() + probe.mockResolvedValue('unverifiable') +}) + +class PresenceTestServer extends AgentHookServer { + applyTranscriptUpdate(): void { + const row = this.state.lastStatusByPaneKey.get(PANE) + if (row) { + this.applyNormalizedStatus({ + ...row, + payload: { ...row.payload, lastAssistantMessage: 'late transcript result' } + }) + } + } +} + +async function createServer(): Promise { + const server = new PresenceTestServer() + servers.push(server) + await server.start({ env: 'production' }) + return server +} + +async function hook( + server: AgentHookServer, + event: string, + session = 'session-a', + reason?: string, + pid: number | null = 4001 +): Promise { + const agentProcess = + pid === null + ? undefined + : JSON.stringify({ pid, platform: process.platform, startTime: `birth-${pid}` }) + const response = await postHookEvent( + server, + buildBody( + { + hook_event_name: event, + session_id: session, + source: 'startup', + reason, + ...(event === 'UserPromptSubmit' ? { prompt: `${session} task` } : {}) + }, + { agentProcess } + ) + ) + expect(response.status).toBe(204) +} + +function state(server: AgentHookServer): string | null { + const row = server.getStatusSnapshot().find((entry) => entry.paneKey === PANE) + return row && !row.providerSessionOnly ? row.state : null +} + +function visible(server: AgentHookServer): boolean { + return server.getStatusSnapshot().some((row) => row.paneKey === PANE && !row.providerSessionOnly) +} + +describe('host-owned hook presence', () => { + it('clears the status on SessionEnd while the terminal survives, without a renderer', async () => { + const server = await createServer() + await hook(server, 'SessionStart') + expect(visible(server)).toBe(true) + await hook(server, 'SessionEnd', 'session-a', 'prompt_input_exit') + expect(visible(server)).toBe(false) + }) + + it.each(['clear', 'resume'])('keeps the running process present through %s', async (reason) => { + const server = await createServer() + await hook(server, 'SessionStart') + await hook(server, 'SessionEnd', 'session-a', reason) + expect(visible(server)).toBe(true) + await hook(server, 'UserPromptSubmit', 'session-b') + expect(state(server)).toBe('working') + await hook(server, 'SessionEnd', 'session-b', 'prompt_input_exit') + expect(visible(server)).toBe(false) + }) + + it('does not resurrect an ended process on a late Stop', async () => { + const server = await createServer() + await hook(server, 'SessionStart') + await hook(server, 'SessionEnd', 'session-a', 'prompt_input_exit') + await hook(server, 'Stop') + expect(visible(server)).toBe(false) + }) + + it('keeps the pane owned by its agent while a nested agent in it starts and ends', async () => { + const server = await createServer() + await hook(server, 'SessionStart', 'outer') + await hook(server, 'UserPromptSubmit', 'outer') + await hook(server, 'SessionStart', 'nested', undefined, 4002) + await hook(server, 'UserPromptSubmit', 'nested', undefined, 4002) + await hook(server, 'SessionEnd', 'nested', 'other', 4002) + expect(visible(server)).toBe(true) + await hook(server, 'PostToolUse', 'outer') + expect(state(server)).toBe('working') + await hook(server, 'SessionEnd', 'outer', 'other') + expect(visible(server)).toBe(false) + }) + + it('never ends a pane from a SessionEnd without a process identity', async () => { + const server = await createServer() + await hook(server, 'SessionStart', 'outer', undefined, null) + await hook(server, 'UserPromptSubmit', 'outer', undefined, null) + await hook(server, 'SessionStart', 'nested', undefined, null) + await hook(server, 'SessionEnd', 'nested', 'other', null) + expect(visible(server)).toBe(true) + await hook(server, 'PostToolUse', 'outer', undefined, null) + expect(state(server)).toBe('working') + }) + + it('lets an unidentified agent keep reporting after an identified nested agent ends', async () => { + const server = await createServer() + await hook(server, 'UserPromptSubmit', 'outer', undefined, null) + await hook(server, 'SessionStart', 'nested', undefined, 4002) + await hook(server, 'SessionEnd', 'nested', 'other', 4002) + expect(visible(server)).toBe(true) + await hook(server, 'PostToolUse', 'outer', undefined, null) + expect(state(server)).toBe('working') + expect(await server.checkAgentPresence(PANE)).toBeNull() + }) + + it('does not let a Claude started inside a working Codex turn end the pane', async () => { + const server = await createServer() + const base = { paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1' } + server.ingestRemote( + { + ...base, + source: 'codex', + hookEventName: 'UserPromptSubmit', + payload: { state: 'working', prompt: 'codex task', agentType: 'codex' } + }, + 'ssh-1' + ) + // The relay has no identity resolution, so it can hand the nested Claude ownership. + server.ingestRemote( + { + ...base, + source: 'claude', + hookEventName: 'SessionEnd', + providerSessionOnly: true, + agentPresence: { + agent: 'claude', + process: { pid: 4002, platform: 'linux', startTime: 'boot:1' }, + ended: true + }, + payload: { state: 'done', prompt: '', agentType: 'claude' } + }, + 'ssh-1' + ) + expect(state(server)).toBe('working') + }) + + it.each([ + ['working', []], + ['idle', ['Stop']] + ])('keeps a %s Codex pane when a Claude run inside it ends', async (_label, codexTail) => { + const server = await createServer() + const codex = async (event: string) => { + const response = await postHookEvent( + server, + buildBody({ hook_event_name: event, session_id: 'codex-a', prompt: 'task' }), + '/hook/codex' + ) + expect(response.status).toBe(204) + } + await codex('UserPromptSubmit') + for (const event of codexTail) { + await codex(event) + } + const before = state(server) + await hook(server, 'SessionStart', 'nested', undefined, 4002) + await hook(server, 'UserPromptSubmit', 'nested', undefined, 4002) + await hook(server, 'SessionEnd', 'nested', 'other', 4002) + expect(visible(server)).toBe(true) + expect(before).not.toBeNull() + expect(await server.checkAgentPresence(PANE)).toBeNull() + }) + + it.each(['devin', 'qoder', 'codebuddy', 'copilot'])( + 'settles %s to done on its own SessionEnd hook', + async (agent) => { + const server = await createServer() + const post = async (payload: Record) => { + const response = await postHookEvent( + server, + buildBody({ session_id: `${agent}-a`, ...payload }), + `/hook/${agent}` + ) + expect(response.status).toBe(204) + } + await post({ hook_event_name: 'UserPromptSubmit', prompt: 'do the task' }) + expect(state(server)).toBe('working') + await post({ hook_event_name: 'SessionEnd', reason: 'prompt_input_exit' }) + expect(state(server)).toBe('done') + } + ) + + it('reports a checkable agent process only for an identified, running owner', async () => { + const server = await createServer() + expect(server.hasVerifiableAgentProcess(PANE)).toBe(false) + await hook(server, 'SessionStart', 'unidentified', undefined, null) + expect(server.hasVerifiableAgentProcess(PANE)).toBe(false) + const identified = await createServer() + await hook(identified, 'SessionStart') + expect(identified.hasVerifiableAgentProcess(PANE)).toBe(true) + await hook(identified, 'SessionEnd', 'session-a', 'prompt_input_exit') + expect(identified.hasVerifiableAgentProcess(PANE)).toBe(false) + }) + + it('checks each pane once after replaying its spooled hooks', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-presence-spool-')) + const first = new AgentHookServer() + servers.push(first) + await first.start({ env: 'production', userDataPath }) + await hook(first, 'SessionStart') + first.flushStatusPersistSync() + first.stop() + const spoolDir = join(userDataPath, 'agent-hooks', 'spool') + mkdirSync(spoolDir, { recursive: true }) + const agentProcess = JSON.stringify({ + pid: 4001, + platform: process.platform, + startTime: 'birth-4001' + }) + const records = Array.from({ length: 40 }, (_, index) => + JSON.stringify({ + paneKey: PANE, + source: 'claude', + receivedAt: Date.now(), + agentProcess, + payload: { + hook_event_name: index % 2 ? 'PreToolUse' : 'PostToolUse', + session_id: 'session-a', + tool_name: 'Bash', + tool_input: { command: 'ls' } + } + }) + ) + writeFileSync(join(spoolDir, 'pane-spooled.jsonl'), `\n${records.join('\n')}\n`) + probe.mockClear() + const restarted = new AgentHookServer() + servers.push(restarted) + await restarted.start({ env: 'production', userDataPath }) + expect(probe).toHaveBeenCalledOnce() + }) + + it('keeps unanswered reads and clears only a positive process exit', async () => { + const server = await createServer() + await hook(server, 'SessionStart') + expect(await server.checkAgentPresence(PANE)).toBe('unverifiable') + expect(visible(server)).toBe(true) + probe.mockResolvedValue('exited') + expect(await server.checkAgentPresence(PANE)).toBe('exited') + expect(visible(server)).toBe(false) + expect(await server.checkAgentPresence(PANE)).toBeNull() + }) + + it('does not apply a delayed process exit to a relaunched agent', async () => { + const server = await createServer() + await hook(server, 'SessionStart') + let finish: (value: 'exited') => void = () => {} + probe.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const pending = server.checkAgentPresence(PANE) + await hook(server, 'SessionEnd', 'session-a', 'prompt_input_exit') + await hook(server, 'SessionStart', 'relaunch', undefined, 4002) + finish('exited') + expect(await pending).toBe('unverifiable') + expect(visible(server)).toBe(true) + }) + + it('accepts a remote exit and never probes that remote PID locally', async () => { + const server = await createServer() + const envelope = { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + source: 'claude', + hookEventName: 'SessionStart', + providerSession: { provider: 'claude', id: 'remote-session' }, + agentPresence: { + agent: 'claude', + process: { pid: process.pid, platform: process.platform, startTime: 'remote-birth' } + }, + payload: { state: 'working', prompt: 'remote task', agentType: 'claude' } + } + server.ingestRemote(envelope, 'ssh-1') + expect(visible(server)).toBe(true) + expect(await server.checkAgentPresence(PANE)).toBe('unverifiable') + expect(probe).not.toHaveBeenCalled() + server.ingestRemote( + { + ...envelope, + hookEventName: 'AgentProcessExit', + providerSessionOnly: true, + agentPresence: { ...envelope.agentPresence, ended: true } + }, + 'ssh-1' + ) + expect(visible(server)).toBe(false) + }) + + it('probes the owner only when another process reports, never on its own hooks or retries', async () => { + const server = await createServer() + await hook(server, 'SessionStart') + await hook(server, 'UserPromptSubmit') + probe.mockResolvedValue('exited') + server.applyTranscriptUpdate() + await Promise.resolve() + expect( + server.getStatusSnapshot().find((row) => row.paneKey === PANE)?.lastAssistantMessage + ).toBe('late transcript result') + expect(probe).not.toHaveBeenCalled() + expect(visible(server)).toBe(true) + await hook(server, 'SessionStart', 'relaunch', undefined, 4002) + await vi.waitFor(() => expect(probe).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(visible(server)).toBe(false)) + await hook(server, 'UserPromptSubmit', 'relaunch', undefined, 4002) + expect(state(server)).toBe('working') + }) +}) diff --git a/src/main/agent-hooks/server-ingest-structured-state-clock.test.ts b/src/main/agent-hooks/server-ingest-structured-state-clock.test.ts index eeb8ffab51ef..8d5fae035458 100644 --- a/src/main/agent-hooks/server-ingest-structured-state-clock.test.ts +++ b/src/main/agent-hooks/server-ingest-structured-state-clock.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' import { AgentHookServer, _internals } from './server' @@ -32,6 +33,22 @@ function summary(over: Partial = {}): AgentSessionSta } } +const CHILD = { idKind: 'task_id', id: 'child-1' } as const + +function liveChild(observedAt: number): AgentChildWorkEvidence { + return { + type: 'live', + observedAt, + child: { + handle: CHILD, + kind: 'agent', + residency: 'background', + state: 'working', + stoppable: true + } + } +} + function row(server: AgentHookServer) { return server.getStatusSnapshot()[0] } @@ -68,16 +85,16 @@ describe("the host row's state clock", () => { summary({ status: 'working', statusStartedAt: 10_000, updatedAt: 10_000 }), SUBJECT ) - server.ingestStructuredStatus( - summary({ - statusStartedAt: SETTLED, - updatedAt: 24_000, - backgroundTasks: [{ id: 'child-1', kind: 'agent', state: 'working' }] - }), - SUBJECT - ) + // The row reads child liveness from the host's records, not the summary's task list. + server.ingestStructuredChildWork(SUBJECT, [liveChild(23_000)], 'codex') + server.ingestStructuredStatus(summary({ statusStartedAt: SETTLED, updatedAt: 24_000 }), SUBJECT) expect(row(server)).toMatchObject({ state: 'working', stateStartedAt: 10_000 }) + server.ingestStructuredChildWork( + SUBJECT, + [{ type: 'ended', observedAt: 25_000, handle: CHILD, outcome: 'succeeded' }], + 'codex' + ) server.ingestStructuredStatus(summary({ statusStartedAt: SETTLED, updatedAt: 26_000 }), SUBJECT) expect(row(server)).toMatchObject({ state: 'done', stateStartedAt: SETTLED }) }) diff --git a/src/main/agent-hooks/server-ingest-structured-status.test.ts b/src/main/agent-hooks/server-ingest-structured-status.test.ts index 654160e6fefa..3752a902dec0 100644 --- a/src/main/agent-hooks/server-ingest-structured-status.test.ts +++ b/src/main/agent-hooks/server-ingest-structured-status.test.ts @@ -4,6 +4,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'nod import { tmpdir } from 'node:os' import { join } from 'node:path' import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import { isFreshNonDoneAgentStatus } from '../../shared/agent-status-freshness' import { structuredAgentSessionPaneKey, @@ -39,6 +40,30 @@ const TAB = structuredAgentSessionTabId(SESSION) const STRUCTURED_PANE = structuredAgentSessionPaneKey(TAB, SESSION) const OBSERVED_AT = 1_757_030_400_000 +/** A child the provider reports live, as the structured producers hand it to the host. */ +function liveChild(id: string, kind: 'agent' | 'command'): AgentChildWorkEvidence { + return { + type: 'live', + observedAt: OBSERVED_AT, + child: { + handle: { idKind: 'task_id', id }, + kind, + residency: 'background', + state: 'working', + stoppable: true + } + } +} + +function endedChild(id: string): AgentChildWorkEvidence { + return { + type: 'ended', + observedAt: OBSERVED_AT + 1, + handle: { idKind: 'task_id', id }, + outcome: 'succeeded' + } +} + function summary(over: Partial = {}): AgentSessionStatusSummary { return { sessionId: SESSION, @@ -103,54 +128,52 @@ describe('AgentHookServer ingestStructuredStatus', () => { expect(server.getStatusSnapshot()[0]?.state).toBe('done') }) - it('folds live background tasks into an idle session the way the hook lane folds a roster', () => { + it("folds the host's live child records into an idle session the way the hook lane folds a roster", () => { const server = new AgentHookServer() - server.ingestStructuredStatus( - summary({ - status: 'idle', - backgroundTasks: [{ id: 'child-1', kind: 'agent', state: 'working' }] - }), - SUBJECT - ) + server.ingestStructuredStatus(summary({ status: 'idle' }), SUBJECT) + server.ingestStructuredChildWork(SUBJECT, [liveChild('child-1', 'agent')], 'codex') + server.ingestStructuredStatus(summary({ status: 'idle', updatedAt: OBSERVED_AT + 1 }), SUBJECT) expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) expect(server.getStatusSnapshot()[0]).not.toHaveProperty('workingMode') - server.ingestStructuredStatus( - summary({ - status: 'idle', - updatedAt: OBSERVED_AT + 1, - backgroundTasks: [{ id: 'shell-1', kind: 'command', state: 'working' }] - }), - SUBJECT + server.ingestStructuredChildWork( + SUBJECT, + [endedChild('child-1'), liveChild('shell-1', 'command')], + 'codex' ) + server.ingestStructuredStatus(summary({ status: 'idle', updatedAt: OBSERVED_AT + 2 }), SUBJECT) expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', workingMode: 'monitoring' }) + server.ingestStructuredChildWork(SUBJECT, [endedChild('shell-1')], 'codex') + server.ingestStructuredStatus(summary({ status: 'idle', updatedAt: OBSERVED_AT + 3 }), SUBJECT) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done' }) + expect(server.getStatusSnapshot()[0]).not.toHaveProperty('workingMode') + }) + + // The summary's task list is derived from these records for older clients; the row reads the + // records themselves, so a list that disagrees with them cannot hold the parent open. + it("reads child liveness from the host's records, never from the summary's task list", () => { + const server = new AgentHookServer() server.ingestStructuredStatus( summary({ status: 'idle', - updatedAt: OBSERVED_AT + 2, - backgroundTasks: [{ id: 'shell-1', kind: 'command', state: 'done' }] + backgroundTasks: [{ id: 'child-1', kind: 'agent', state: 'working' }] }), SUBJECT ) expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done' }) - expect(server.getStatusSnapshot()[0]).not.toHaveProperty('workingMode') }) // The timer beside the row belongs to the state it labels: monitoring that becomes a real turn // must not report the watch loop's age as how long the agent has been working. it('restarts the state clock when monitoring becomes a real turn', () => { const server = new AgentHookServer() - server.ingestStructuredStatus( - summary({ - status: 'idle', - backgroundTasks: [{ id: 'shell-1', kind: 'command', state: 'working' }] - }), - SUBJECT - ) + server.ingestStructuredStatus(summary({ status: 'idle' }), SUBJECT) + server.ingestStructuredChildWork(SUBJECT, [liveChild('shell-1', 'command')], 'codex') + server.ingestStructuredStatus(summary({ status: 'idle' }), SUBJECT) expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', workingMode: 'monitoring', @@ -158,11 +181,7 @@ describe('AgentHookServer ingestStructuredStatus', () => { }) server.ingestStructuredStatus( - summary({ - status: 'working', - updatedAt: OBSERVED_AT + 2_700_000, - backgroundTasks: [{ id: 'shell-1', kind: 'command', state: 'working' }] - }), + summary({ status: 'working', updatedAt: OBSERVED_AT + 2_700_000 }), SUBJECT ) expect(server.getStatusSnapshot()[0]).toMatchObject({ @@ -178,14 +197,9 @@ describe('AgentHookServer ingestStructuredStatus', () => { it('dates a task edge by when the host saw it, not by the journal clock', () => { const server = new AgentHookServer() const before = Date.now() - server.ingestStructuredStatus( - summary({ - status: 'idle', - updatedAt: OBSERVED_AT, - backgroundTasks: [{ id: 'shell-1', kind: 'command', state: 'working' }] - }), - SUBJECT - ) + server.ingestStructuredStatus(summary({ status: 'idle', updatedAt: OBSERVED_AT }), SUBJECT) + server.ingestStructuredChildWork(SUBJECT, [liveChild('shell-1', 'command')], 'codex') + server.ingestStructuredStatus(summary({ status: 'idle', updatedAt: OBSERVED_AT }), SUBJECT) const row = server.getStatusSnapshot()[0] expect(row).toMatchObject({ state: 'working', workingMode: 'monitoring' }) expect(row?.evidenceObservedAt).toBeGreaterThanOrEqual(before) @@ -354,10 +368,9 @@ describe('structured rows and last-status.json', () => { describe('the main agent fact on a structured row', () => { it('publishes the main agent beside the folded state, on the journal clock with continuity', () => { const server = new AgentHookServer() - server.ingestStructuredStatus( - summary({ status: 'idle', backgroundTasks: [{ id: 'c', kind: 'agent', state: 'working' }] }), - SUBJECT - ) + server.ingestStructuredStatus(summary({ status: 'idle' }), SUBJECT) + server.ingestStructuredChildWork(SUBJECT, [liveChild('c', 'agent')], 'codex') + server.ingestStructuredStatus(summary({ status: 'idle' }), SUBJECT) expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', mainAgent: { state: 'done', stateStartedAt: OBSERVED_AT } @@ -365,6 +378,7 @@ describe('the main agent fact on a structured row', () => { expect(server.getStatusSnapshot()[0]?.mainAgent).not.toHaveProperty('outcome') // The main agent is still done while its child drains: the main agent's clock does not move. + server.ingestStructuredChildWork(SUBJECT, [endedChild('c')], 'codex') server.ingestStructuredStatus( summary({ status: 'idle', updatedAt: OBSERVED_AT + 5, turnOutcome: 'failure' }), SUBJECT diff --git a/src/main/agent-hooks/server-last-status-lead-boundary.test.ts b/src/main/agent-hooks/server-last-status-lead-boundary.test.ts index 7b7307d79159..a78476f21bcd 100644 --- a/src/main/agent-hooks/server-last-status-lead-boundary.test.ts +++ b/src/main/agent-hooks/server-last-status-lead-boundary.test.ts @@ -217,62 +217,6 @@ describe('Persisted Claude lead boundaries', () => { } }) - it('clears a persisted lead boundary when sticky permission hides new lead work', async () => { - const firstServer = new AgentHookServer() - await firstServer.start({ env: 'production', userDataPath }) - await postHookEvent( - firstServer, - buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'resume after boundary' }) - ) - await postHookEvent( - firstServer, - buildBody({ hook_event_name: 'SubagentStart', agent_id: 'achild-a' }) - ) - await postHookEvent( - firstServer, - buildBody({ hook_event_name: 'SubagentStart', agent_id: 'achild-b' }) - ) - await postHookEvent(firstServer, buildBody({ hook_event_name: 'Stop' })) - await postHookEvent( - firstServer, - buildBody({ - hook_event_name: 'PermissionRequest', - agent_id: 'achild-a', - tool_name: 'Bash', - tool_input: { command: 'false' } - }) - ) - await postHookEvent( - firstServer, - buildBody({ hook_event_name: 'PreToolUse', tool_name: 'Read' }) - ) - expect(firstServer.getStatusSnapshot()[0]).toMatchObject({ state: 'waiting', toolName: 'Bash' }) - expect(mainAgentState(firstServer)).toBe('working') - await postHookEvent( - firstServer, - buildBody({ hook_event_name: 'SubagentStop', agent_id: 'achild-a' }) - ) - firstServer.flushStatusPersistSync() - firstServer.stop() - - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - await postHookEvent( - server, - buildBody({ hook_event_name: 'SubagentStop', agent_id: 'achild-b' }) - ) - - expect(server.getStatusSnapshot()[0]).toMatchObject({ - state: 'working', - restoredUnconfirmed: true - }) - expect(server.getStatusChangeSnapshot()[0]?.observedInCurrentRuntime).toBe(false) - } finally { - server.stop() - } - }) - it('keeps a persisted lead boundary unconfirmed after an unrelated child stop', async () => { const firstServer = new AgentHookServer() await firstServer.start({ env: 'production', userDataPath }) diff --git a/src/main/agent-hooks/server-opencode-binder.test.ts b/src/main/agent-hooks/server-opencode-binder.test.ts index 261de8888ac2..eaa5dff073e2 100644 --- a/src/main/agent-hooks/server-opencode-binder.test.ts +++ b/src/main/agent-hooks/server-opencode-binder.test.ts @@ -29,8 +29,8 @@ class BinderTestServer extends AgentHookServer { this.startOpenCodeBinderLoop() } - public ingest(source: AgentHookSource, body: unknown): void { - this.normalizeLocalHookPayload(source, body) + public ingest(source: AgentHookSource, body: unknown): string | undefined { + return this.normalizeLocalHookPayload(source, body).event?.paneKey } public readRegistry(sessionId: string): string | undefined { @@ -38,16 +38,16 @@ class BinderTestServer extends AgentHookServer { } } -function writeDb(dbPath: string, table: 'session_v2' | 'session'): void { +function writeDb(dbPath: string, table: 'session_v2' | 'session', id = 'ses_live'): void { const db = new SyncDatabase(dbPath) try { db.exec( - `CREATE TABLE ${table} (id TEXT PRIMARY KEY, directory TEXT NOT NULL, time_created INTEGER NOT NULL, parent_id TEXT)` + `CREATE TABLE IF NOT EXISTS ${table} (id TEXT PRIMARY KEY, directory TEXT NOT NULL, time_created INTEGER NOT NULL, parent_id TEXT)` ) const insert = db.prepare( `INSERT INTO ${table} (id, directory, time_created, parent_id) VALUES (?, ?, ?, ?)` ) - insert.run('ses_live', DIR, Date.now() - 60_000, null) + insert.run(id, DIR, Date.now() - 60_000, null) } finally { db.close() } @@ -85,22 +85,21 @@ describe('opencode binder loop', () => { rmSync(dir, { recursive: true, force: true }) }) - it('binds a fresh session to its pane', async () => { - writeDb(dbPath, 'session_v2') + it('binds a fresh OpenCode 1 session to its pane', async () => { + writeDb(dbPath, 'session') const applied = await server.runBinderRound() expect(applied).toBe(1) expect(server.readRegistry('ses_live')).toBe(PANE_A) }) - it('falls back to the v1 session table', async () => { - writeDb(dbPath, 'session') - const applied = await server.runBinderRound() - expect(applied).toBe(1) - expect(server.readRegistry('ses_live')).toBe(PANE_A) + it('never binds an OpenCode 2 session', async () => { + writeDb(dbPath, 'session_v2') + expect(await server.runBinderRound()).toBe(0) + expect(server.readRegistry('ses_live')).toBeUndefined() }) it('an opencode SessionStart kicks a round that binds before the poll', async () => { - writeDb(dbPath, 'session_v2') + writeDb(dbPath, 'session') vi.useFakeTimers() try { // Birth arrives stamped with the wrong (server-starter) pane. @@ -117,8 +116,27 @@ describe('opencode binder loop', () => { } }) + it('an OpenCode 2 SessionStart kicks no round', async () => { + writeDb(dbPath, 'session') + const sweep = vi.fn(async () => []) + server.bindDeps({ sweep }) + vi.useFakeTimers() + try { + server.ingest('opencode', { + paneKey: PANE_B, + launchToken: '', + opencodeMajor: 2, + payload: { hook_event_name: 'SessionStart', sessionID: 'ses_live' } + }) + await vi.advanceTimersByTimeAsync(10_000) + expect(sweep).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + it('pane teardown unbinds its sessions', async () => { - writeDb(dbPath, 'session_v2') + writeDb(dbPath, 'session') await server.runBinderRound() expect(server.readRegistry('ses_live')).toBe(PANE_A) server.clearPaneState(PANE_A) @@ -126,12 +144,12 @@ describe('opencode binder loop', () => { }) it('stops the loop without hanging the process', () => { - writeDb(dbPath, 'session_v2') + writeDb(dbPath, 'session') expect(() => server.stop()).not.toThrow() }) it('runs a round immediately on loop start', async () => { - writeDb(dbPath, 'session_v2') + writeDb(dbPath, 'session') server.startBinderLoop() try { await vi.waitFor(() => expect(server.readRegistry('ses_live')).toBe(PANE_A)) @@ -141,7 +159,7 @@ describe('opencode binder loop', () => { }) it('discards a round that was in flight across stop', async () => { - writeDb(dbPath, 'session_v2') + writeDb(dbPath, 'session') let releaseSweep!: () => void const sweepGate = new Promise((resolve) => { releaseSweep = resolve @@ -168,7 +186,7 @@ describe('opencode binder loop', () => { }) it('an obsolete round does not clear the new round running flag', async () => { - writeDb(dbPath, 'session_v2') + writeDb(dbPath, 'session') let releaseFirst!: () => void let releaseLater!: () => void const firstGate = new Promise((resolve) => { @@ -212,6 +230,61 @@ describe('opencode binder loop', () => { }) }) +// OpenCode 1 `serve` in pane A stamps every post with pane A; `attach` in pane B drives the session. +describe('OpenCode 1 serve + attach', () => { + let dir = '' + let dbPath = '' + let server: BinderTestServer + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'binder-attach-')) + dbPath = join(dir, 'opencode.db') + writeDb(dbPath, 'session') + const startedAtMs = Date.now() - 120_000 + server = new BinderTestServer() + server.bindDeps({ + now: () => Date.now(), + dbPath: () => dbPath, + listPanes: () => [ + { paneKey: PANE_A, directory: DIR, worktreeId: `repo::${DIR}`, shellPid: 111 }, + { paneKey: PANE_B, directory: DIR, worktreeId: `repo::${DIR}`, shellPid: 211 } + ], + sweep: async () => [ + { pid: 112, ppid: 111, startedAtMs, executable: 'opencode', argv: ['opencode', 'serve'] }, + { + pid: 212, + ppid: 211, + startedAtMs, + executable: 'opencode', + argv: ['opencode', 'attach', 'http://127.0.0.1:4096'] + } + ] + }) + }) + + afterEach(() => { + server.stop() + rmSync(dir, { recursive: true, force: true }) + }) + + const busy = (extra: Record = {}): Record => ({ + paneKey: PANE_A, + launchToken: '', + ...extra, + payload: { hook_event_name: 'SessionBusy', sessionID: 'ses_live' } + }) + + it("reports the session on the attaching pane, not the server's", async () => { + expect(await server.runBinderRound()).toBe(1) + expect(server.ingest('opencode', busy())).toBe(PANE_B) + }) + + it('never moves an OpenCode 2 post with the same shape', async () => { + await server.runBinderRound() + expect(server.ingest('opencode', busy({ opencodeMajor: 2 }))).toBe(PANE_A) + }) +}) + describe('listOpenCodeDbSessions', () => { let dir = '' let dbPath = '' @@ -225,14 +298,22 @@ describe('listOpenCodeDbSessions', () => { rmSync(dir, { recursive: true, force: true }) }) - it('reads session_v2 rows newer than the watermark', () => { - writeDb(dbPath, 'session_v2') + it('reads OpenCode 1 session rows newer than the watermark', () => { + writeDb(dbPath, 'session') const rows = listOpenCodeDbSessions(dbPath, { ms: 0, id: '' }) expect(rows).toHaveLength(1) expect(rows[0]).toMatchObject({ id: 'ses_live', directory: DIR, parentId: null }) expect(listOpenCodeDbSessions(dbPath, { ms: Date.now(), id: '' })).toEqual([]) }) + it('skips OpenCode 2 rows in a database both versions wrote', () => { + writeDb(dbPath, 'session', 'ses_v1') + writeDb(dbPath, 'session_v2', 'ses_v2') + expect(listOpenCodeDbSessions(dbPath, { ms: 0, id: '' }).map((row) => row.id)).toEqual([ + 'ses_v1' + ]) + }) + it('returns [] for a missing database instead of throwing', () => { expect(listOpenCodeDbSessions(join(dir, 'absent.db'), { ms: 0, id: '' })).toEqual([]) }) diff --git a/src/main/agent-hooks/server-process-lifetime-status.test.ts b/src/main/agent-hooks/server-process-lifetime-status.test.ts new file mode 100644 index 000000000000..e0f489299af1 --- /dev/null +++ b/src/main/agent-hooks/server-process-lifetime-status.test.ts @@ -0,0 +1,112 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AgentHookServer } from './server' +import { buildBody, PANE, postHookEvent } from './server.test-fixtures' + +let dir: string +let server: AgentHookServer + +beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), 'orca-process-lifetime-')) + server = new AgentHookServer() + await server.start({ env: 'production', userDataPath: dir }) +}) + +afterEach(() => { + server.stop() + rmSync(dir, { recursive: true, force: true }) +}) + +function processLifetime(state: 'working' | 'done', yieldsToHookSince: number): void { + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + origin: 'process', + yieldsToHookSince, + payload: { state, prompt: '', agentType: 'opencode' } + }) +} + +async function openCodeHook(hookEventName: string): Promise { + const response = await postHookEvent( + server, + buildBody({ hook_event_name: hookEventName, sessionID: 'ses_1' }), + '/hook/opencode' + ) + expect(response.status).toBe(204) +} + +function paneState(): string { + return server.getStatusSnapshotForPane(PANE)[0]?.state ?? 'missing' +} + +async function nextMillisecond(): Promise { + await new Promise((resolve) => setTimeout(resolve, 5)) +} + +// One `opencode run` command in one pane: the host reports it from the process lifetime unless a +// hook producer (OpenCode 1's in-process plugin) reports the same pane during that command. +describe('process-lifetime status', () => { + it('reports Working, then Done, when no hook speaks for the command', () => { + const commandStartedAt = Date.now() + processLifetime('working', commandStartedAt) + expect(paneState()).toBe('working') + processLifetime('done', commandStartedAt) + expect(paneState()).toBe('done') + }) + + it('yields the rest of the command once a hook reports the pane', async () => { + const commandStartedAt = Date.now() + processLifetime('working', commandStartedAt) + await openCodeHook('SessionBusy') + // The hook is still Working, so the process exit must not write a second Done over it. + processLifetime('done', commandStartedAt) + expect(paneState()).toBe('working') + await openCodeHook('SessionIdle') + expect(paneState()).toBe('done') + }) + + it('writes no Working over a hook that already claimed the command', async () => { + const commandStartedAt = Date.now() + await openCodeHook('SessionIdle') + processLifetime('working', commandStartedAt) + expect(paneState()).toBe('done') + }) + + it('does not yield to a hook row from before the command started', async () => { + await openCodeHook('SessionIdle') + await nextMillisecond() + const commandStartedAt = Date.now() + processLifetime('working', commandStartedAt) + expect(paneState()).toBe('working') + processLifetime('done', commandStartedAt) + expect(paneState()).toBe('done') + }) + + // Why: a pane where an Orca-launched agent exited is retired; a new `opencode run` there is a new run. + it('revives a retired pane on its Working, as a hook new-turn event does', () => { + server.retirePaneAuthority(PANE) + const commandStartedAt = Date.now() + processLifetime('working', commandStartedAt) + expect(paneState()).toBe('working') + processLifetime('done', commandStartedAt) + expect(paneState()).toBe('done') + }) + + it('keeps OSC status and a lone process Done out of a retired pane', () => { + server.retirePaneAuthority(PANE) + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + payload: { state: 'working', prompt: '', agentType: 'opencode' } + }) + processLifetime('done', Date.now()) + expect(paneState()).toBe('missing') + }) +}) diff --git a/src/main/agent-hooks/server.test-fixtures.ts b/src/main/agent-hooks/server.test-fixtures.ts index 12ef7e9c6e95..99bb0aeee696 100644 --- a/src/main/agent-hooks/server.test-fixtures.ts +++ b/src/main/agent-hooks/server.test-fixtures.ts @@ -20,6 +20,7 @@ export type Body = { worktreeId?: string env?: string version?: string + agentProcess?: string payload: Record } diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index b2aedfc2dfcb..5c88dfd62d8c 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -4,7 +4,7 @@ import { normalizeHookPayload } from '../../shared/agent-hook-listener' import { parseFormEncodedBody } from '../../shared/agent-hook-listener/request-body' import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' import type { AgentHookSource } from '../../shared/agent-hook-relay' -import { AgentHookServerLifecycle } from './server/server-lifecycle' +import { AgentHookServerAgentPresence } from './server/server-agent-presence' import { isValidPaneKey } from './server/server-status-identity' export type { @@ -26,7 +26,7 @@ export { export { isValidPaneKey } /** Public composition seam for the loopback hook listener and relay status adapter. */ -export class AgentHookServer extends AgentHookServerLifecycle {} +export class AgentHookServer extends AgentHookServerAgentPresence {} export const agentHookServer = new AgentHookServer() diff --git a/src/main/agent-hooks/server/server-agent-presence.ts b/src/main/agent-hooks/server/server-agent-presence.ts new file mode 100644 index 000000000000..ec09832ccb85 --- /dev/null +++ b/src/main/agent-hooks/server/server-agent-presence.ts @@ -0,0 +1,71 @@ +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import { + isSameAgentProcess, + type AgentProcessVerdict +} from '../../../shared/agent-process-presence' +import { probeAgentProcessPresence } from '../../../shared/agent-process-presence-probe' +import { AgentHookServerLifecycle } from './server-lifecycle' + +export abstract class AgentHookServerAgentPresence extends AgentHookServerLifecycle { + private readonly presenceChecks = new WeakMap< + AgentHookEventPayload, + Promise + >() + + /** A live hook proves its own process alive; only another process's hook casts doubt on the owner. */ + checkAgentPresenceAfterHook(event: AgentHookEventPayload, row: AgentHookEventPayload): void { + const sender = event.agentPresence?.process + const owner = row.agentPresence + if (sender && owner?.process && !owner.ended && !isSameAgentProcess(sender, owner.process)) { + void this.checkAgentPresence(row.paneKey) + } + } + + /** Whether this pane's owner carries a process identity that its execution host can check. */ + hasVerifiableAgentProcess(paneKey: string): boolean { + const presence = this.state.lastStatusByPaneKey.get( + this.resolvePaneKeyAlias(paneKey) + )?.agentPresence + return presence?.process !== undefined && !presence.ended + } + + checkAgentPresence(paneKey: string): Promise { + const resolved = this.resolvePaneKeyAlias(paneKey) + const row = this.state.lastStatusByPaneKey.get(resolved) + // Why: an ended owner already published its exit, and an owner no hook identified cannot be checked. + if (!row?.agentPresence?.process || row.agentPresence.ended) { + return Promise.resolve(null) + } + if (row.connectionId !== null) { + return Promise.resolve('unverifiable') + } + const pending = this.presenceChecks.get(row) + if (pending) { + return pending + } + const presence = row.agentPresence + const check = probeAgentProcessPresence(presence.process) + .then((verdict) => { + if ( + this.state.lastStatusByPaneKey.get(resolved) !== row || + row.agentPresence !== presence + ) { + return 'unverifiable' as const + } + if (verdict === 'exited') { + this.reconcileEndedProcessForPaneKeys([resolved], { + preserveResumeIdentity: true, + endedPresence: { ...presence, ended: true } + }) + } + return verdict + }) + .finally(() => { + if (this.presenceChecks.get(row) === check) { + this.presenceChecks.delete(row) + } + }) + this.presenceChecks.set(row, check) + return check + } +} diff --git a/src/main/agent-hooks/server/server-cleanup.ts b/src/main/agent-hooks/server/server-cleanup.ts index f92e34478669..71be07e61703 100644 --- a/src/main/agent-hooks/server/server-cleanup.ts +++ b/src/main/agent-hooks/server/server-cleanup.ts @@ -1,3 +1,4 @@ +import type { AgentProcessPresence } from '../../../shared/agent-process-presence' import { admitLegacyAgentStatus, deleteLegacyAgentStatus, @@ -115,6 +116,7 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen reconcileEndedProcessForPaneKeys( paneKeys: Iterable, options?: { + endedPresence?: AgentProcessPresence /** The pane's PTY outlived its agent (a confirmed shell foreground), so the session can still * be resumed in place — keep the `providerSessionOnly` remnant the paired `agentStatus:drop` * minted for exactly this case. A certified PTY exit passes nothing: there is no pane left to @@ -129,13 +131,17 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen if (!this.hasLiveClaimsForPaneKey(resolvedPaneKey)) { continue } - const retained = options?.preserveResumeIdentity + const resumeRow = options?.preserveResumeIdentity ? this.toRetainedProviderSessionRow( this.state.lastStatusByPaneKey.get(resolvedPaneKey) as | EnrichedAgentHookEventPayload | undefined ) : null + const retained = + resumeRow && options?.endedPresence + ? { ...resumeRow, agentPresence: options.endedPresence } + : resumeRow const previous = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as | EnrichedAgentHookEventPayload | undefined diff --git a/src/main/agent-hooks/server/server-ingest-normalization.ts b/src/main/agent-hooks/server/server-ingest-normalization.ts index 171a15ac5cc8..5e71e6ef033f 100644 --- a/src/main/agent-hooks/server/server-ingest-normalization.ts +++ b/src/main/agent-hooks/server/server-ingest-normalization.ts @@ -1,6 +1,7 @@ import { buildSpoolHookBody, type SpoolRecord } from '../../../shared/agent-hook-spool' import { normalizeHookPayload } from '../../../shared/agent-hook-listener' import { isAgentHookSource, type AgentHookSource } from '../../../shared/agent-hook-relay' +import { isOpenCodeSharedServerPost } from '../../../shared/agent-hook-listener/opencode-session-registry' import type { NormalizedLocalHook } from './server-types' import { AgentHookServerOpenCodeBinder } from './server-opencode-binder' @@ -27,8 +28,8 @@ export abstract class AgentHookServerIngestNormalization extends AgentHookServer const event = normalizeHookPayload(this.state, source, body, this.env) if ( event && - (source === 'opencode' || source === 'mimo-code') && - event.hookEventName === 'SessionStart' + event.hookEventName === 'SessionStart' && + isOpenCodeSharedServerPost(source, body) ) { // Why: a birth just arrived; bind it now instead of waiting out the poll interval. this.kickOpenCodeBinder() diff --git a/src/main/agent-hooks/server/server-ingest-remote.ts b/src/main/agent-hooks/server/server-ingest-remote.ts index e2e1c91fe80b..28eac6e56dc1 100644 --- a/src/main/agent-hooks/server/server-ingest-remote.ts +++ b/src/main/agent-hooks/server/server-ingest-remote.ts @@ -1,3 +1,4 @@ +import { readAgentProcessPresence } from '../../../shared/agent-process-presence' import { track } from '../../telemetry/client' import { normalizeAgentStatusPayload } from '../../../shared/agent-status-types' import { restoreShedStatusFields } from '../../../shared/agent-hook-relay' @@ -35,6 +36,7 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS launchToken?: string hasExplicitPrompt?: boolean promptInteractionKey?: string + agentPresence?: unknown hookEventName?: string source?: unknown providerPromptId?: unknown @@ -156,6 +158,7 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS ) if ( envelope.providerSessionOnly === true && + !readAgentProcessPresence(envelope.agentPresence)?.ended && !isValidPiProviderSessionOnly(providerSession, normalizedPayload.agentType) ) { return @@ -256,6 +259,7 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS }) const event: AgentHookEventPayload & { authorityRestartId?: string } = { paneKey, + agentPresence: readAgentProcessPresence(envelope.agentPresence), source: effectiveSource, ...(restartedAuthority?.authorityRestartId ? { authorityRestartId: restartedAuthority.authorityRestartId } diff --git a/src/main/agent-hooks/server/server-ingest-structured-children.ts b/src/main/agent-hooks/server/server-ingest-structured-children.ts index ecc58b998d77..1c3eb992d451 100644 --- a/src/main/agent-hooks/server/server-ingest-structured-children.ts +++ b/src/main/agent-hooks/server/server-ingest-structured-children.ts @@ -7,6 +7,10 @@ import { reconcileAgentChildWorkEvidence, type AgentChildWorkReconcileOutcome } from '../../../shared/agent-status-child-work-reconciliation' +import { + projectAgentChildWorkViews, + type AgentChildWorkView +} from '../../../shared/agent-status-child-work-view' import { parseAgentStatusSubject, type AgentStatusStructuredSessionSubject @@ -57,4 +61,13 @@ export abstract class AgentHookServerIngestStructuredChildren extends AgentHookS const parent = parseAgentStatusSubject(subject) return parent ? this.canonicalStatusStore.getChildren(parent) : [] } + + /** The same records as the views every surface reads; the one record-to-view projection. */ + getStructuredChildWorkViews(subject: AgentStatusStructuredSessionSubject): AgentChildWorkView[] { + const children = this.getStructuredChildWork(subject) + return projectAgentChildWorkViews( + children, + children.flatMap((child) => this.canonicalStatusStore.getAliasesForChild(child.childWorkId)) + ) + } } diff --git a/src/main/agent-hooks/server/server-ingest-structured.ts b/src/main/agent-hooks/server/server-ingest-structured.ts index 19ee70e88af3..6255c57b5ca9 100644 --- a/src/main/agent-hooks/server/server-ingest-structured.ts +++ b/src/main/agent-hooks/server/server-ingest-structured.ts @@ -44,9 +44,11 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng } const previous = this.canonicalStatusStore.getParent(parsed) const priorStatus = previous?.status + // The host's own child records, not the summary's lossy task list: the summary is derived + // from these records, and reading them here keeps the row and every view of it on one source. const agentStatus = structuredAgentSessionAgentStatus({ status: summary.status, - backgroundTasks: summary.backgroundTasks, + childWork: this.canonicalStatusStore.getChildren(parsed), turnOutcome: summary.turnOutcome }) const { state, workingMode } = agentStatus diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts index 9a83465d3b44..63355e3592b2 100644 --- a/src/main/agent-hooks/server/server-ingest-terminal.ts +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -16,6 +16,10 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges connectionId?: string | null terminalHandle?: string payload: ParsedAgentStatusPayload + /** `process`: derived from the pane's foreground process rather than parsed from its bytes. */ + origin?: 'process' + /** Drop this write when a hook has reported the pane since then (the hook owns that command). */ + yieldsToHookSince?: number }): void { const physicalPaneKey = event.paneKey.trim() let paneKey = this.resolvePaneKeyAlias(physicalPaneKey) @@ -43,9 +47,19 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges return } const tabId = paneKey !== physicalPaneKey ? parsedPaneKey?.tabId : reportedTabId - if (this.getAgentStatusDisposition(paneKey) !== 'accept') { + // Why: a verified process-lifetime Working proves a new agent run, as a hook new-turn event does. + const disposition = this.getAgentStatusDisposition( + paneKey, + event.origin === 'process' && event.payload.state === 'working' + ? { processNewTurn: true } + : undefined + ) + if (disposition === 'suppress') { return } + if (disposition === 'restart') { + this.observations.rebind(paneKey) + } const worktreeId = event.worktreeId !== undefined && event.worktreeId.trim().length > 0 ? event.worktreeId.trim() @@ -82,15 +96,21 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges const previous = this.state.lastStatusByPaneKey.get(paneKey) as | EnrichedAgentHookEventPayload | undefined + // Why: a hook that reported during this command owns it (OpenCode 1 `run` loads its plugin in-process). + const hookOwnsCommand = + event.yieldsToHookSince !== undefined && + previous?.observation?.origin === 'hook' && + previous.receivedAt >= event.yieldsToHookSince if ( - previous?.payload.agentType === 'claude' && - event.payload.agentType === 'claude' && - isAgentStatusHeldOpenByChildWork(previous.payload) && - previous.payload.subagents?.some((subagent) => subagent.state === 'working') === true + hookOwnsCommand || + (previous?.payload.agentType === 'claude' && + event.payload.agentType === 'claude' && + isAgentStatusHeldOpenByChildWork(previous.payload) && + previous.payload.subagents?.some((subagent) => subagent.state === 'working') === true) ) { // Why: OSC carries no child identity, so it cannot settle or repaint a row child agents hold open // (working, or waiting on a child's prompt); their lifecycle hooks will. - if (mutationBefore !== undefined) { + if (previous && mutationBefore !== undefined) { this.commitStatusRowMutation(mutationBefore, previous) this.emitEnrichedStatus(previous) } @@ -155,7 +175,7 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges : event.payload }, undefined, - 'osc', + event.origin ?? 'osc', undefined, mutationBefore ) diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index 816058e55fdd..e6e1bf80061d 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -45,12 +45,20 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.captureHydratedAuthorityCommitments() // Drain before binding the listener so replay cannot race a live hook during startup. if (this.endpointDir) { + const replayedPaneKeys = new Set() drainAgentHookSpool({ endpointDir: this.endpointDir, getPersistedLaunchTokenHash: (paneKey) => this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), - ingest: (record: SpoolRecord) => this.ingestSpoolRecord(record) + ingest: (record: SpoolRecord) => { + this.ingestSpoolRecord(record) + replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey)) + } }) + // Why: the owner may have died while Orca was down; check each replayed pane once. + for (const paneKey of replayedPaneKeys) { + void this.checkAgentPresence(paneKey) + } } this.ownerStateInitialized = true } @@ -131,6 +139,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.recordCurrentAuthorityObservation(event) const enriched = this.applyNormalizedStatus(event, normalized.onAccepted) if (enriched) { + this.checkAgentPresenceAfterHook(event, enriched) this.scheduleAssistantMessageRetry(source, aliasedBody, enriched) this.scheduleTranscriptPoll(source, aliasedBody, enriched) } diff --git a/src/main/agent-hooks/server/server-opencode-binder.ts b/src/main/agent-hooks/server/server-opencode-binder.ts index 32f8e6775399..48a75ba54832 100644 --- a/src/main/agent-hooks/server/server-opencode-binder.ts +++ b/src/main/agent-hooks/server/server-opencode-binder.ts @@ -35,7 +35,8 @@ export type OpenCodeBinderLoopDeps = { } /** - * Session→pane binder loop for the shared OpenCode server (#21359). + * Session→pane binder loop for the OpenCode 1 `serve` process shared by + * `attach` panes (#21359). OpenCode-1-only: remove with OpenCode 1 support. * * Sits just above persistence in the chain so ingest layers can kick a round * when a birth arrives early, and lifecycle can start/stop the timer. All diff --git a/src/main/agent-hooks/server/server-persistence-validation.ts b/src/main/agent-hooks/server/server-persistence-validation.ts index 7203f174a0ee..c4913e29c0e7 100644 --- a/src/main/agent-hooks/server/server-persistence-validation.ts +++ b/src/main/agent-hooks/server/server-persistence-validation.ts @@ -1,3 +1,4 @@ +import { readAgentProcessPresence } from '../../../shared/agent-process-presence' import { createHash } from 'node:crypto' import { normalizeAgentProviderSession } from '../../../shared/agent-session-resume' @@ -152,6 +153,7 @@ export function sanitizeHydratedEntry( const turnStartedAt = record.turnStartedAt return { paneKey, + agentPresence: readAgentProcessPresence(record.agentPresence), source, tabId: typeof tabId === 'string' ? tabId : undefined, worktreeId: typeof worktreeId === 'string' ? worktreeId : undefined, diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index 8ba89b9663bb..49bb8ade3ac9 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -1,3 +1,7 @@ +import type { + AgentProcessPresence, + AgentProcessVerdict +} from '../../../shared/agent-process-presence' import type { createServer } from 'node:http' import { randomBytes, randomUUID } from 'node:crypto' @@ -255,6 +259,17 @@ export abstract class AgentHookServerState { entry: EnrichedAgentHookEventPayload | null | undefined ): EnrichedAgentHookEventPayload | null protected abstract hasLiveClaimsForPaneKey(paneKey: string): boolean + abstract checkAgentPresence(paneKey: string): Promise + abstract checkAgentPresenceAfterHook( + event: AgentHookEventPayload, + row: AgentHookEventPayload + ): void + + abstract reconcileEndedProcessForPaneKeys( + paneKeys: Iterable, + options?: { preserveResumeIdentity?: boolean; endedPresence?: AgentProcessPresence } + ): number + protected abstract clearPaneState( paneKey: string, options?: { emitStatusRowMutation?: boolean } diff --git a/src/main/agent-hooks/server/server-status-disposition.ts b/src/main/agent-hooks/server/server-status-disposition.ts index e8ac4ddd31ef..ef633d7c2c80 100644 --- a/src/main/agent-hooks/server/server-status-disposition.ts +++ b/src/main/agent-hooks/server/server-status-disposition.ts @@ -50,6 +50,8 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt isReplay?: boolean hasExplicitPrompt?: boolean launchToken?: string + /** A process-lifetime Working: a fresh command whose foreground argv proves a new agent run. */ + processNewTurn?: boolean } ): 'accept' | 'restart' | 'suppress' { const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) @@ -94,7 +96,7 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt return 'accept' } } - if (event && tokenFence) { + if (event && event.processNewTurn !== true && tokenFence) { const launchToken = event.launchToken?.trim() if (!launchToken || createHash('sha256').update(launchToken).digest('hex') !== tokenFence) { return 'suppress' @@ -134,7 +136,10 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt // Why the token is minted here: a revive proves a live lifecycle, and fencing follow-up // status on that launch token stops a stale process reclaiming the pane's row without // restoring retired orchestration authority. - if ((isNewTurn || freshOpenCodeFamilyPrompt) && event?.isReplay !== true) { + if ( + (isNewTurn || freshOpenCodeFamilyPrompt || event?.processNewTurn === true) && + event?.isReplay !== true + ) { this.closedAgentStatusPaneKeys.delete(paneKey) this.closedAgentStatusPaneKeys.delete(ownerPaneKey) const launchToken = event?.launchToken?.trim() diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index a37cbb02eb44..bb40c187134f 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -1,3 +1,4 @@ +import { transitionHookPresence } from '../../../shared/agent-hook-presence-transition' import { reconcileRemoteCodexState, markCodexLeadTurnInterrupted @@ -27,10 +28,24 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA observedAt?: number, mutationBefore?: EnrichedAgentHookEventPayload ): EnrichedAgentHookEventPayload | undefined { - const { authorityRestartId, ...payload } = incoming + const transitioned = transitionHookPresence( + incoming, + this.state.lastStatusByPaneKey.get(incoming.paneKey) + ) + if (!transitioned) { + return undefined + } + const { authorityRestartId, ...payload } = { ...incoming, ...transitioned } if (!this.canWriteLegacyStatusRow(payload)) { return undefined } + if (payload.agentPresence?.ended) { + this.reconcileEndedProcessForPaneKeys([payload.paneKey], { + preserveResumeIdentity: true, + endedPresence: payload.agentPresence + }) + return undefined + } if (payload.hookEventName === 'UserPromptSubmit') { // Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp. this.activeHookTurnCompletedAtByPaneKey.delete(payload.paneKey) diff --git a/src/main/agent-hooks/spool.test.ts b/src/main/agent-hooks/spool.test.ts index 24fcef6be5d3..23917887234c 100644 --- a/src/main/agent-hooks/spool.test.ts +++ b/src/main/agent-hooks/spool.test.ts @@ -31,7 +31,9 @@ describe('agent hook spool', () => { ) expect(spoolLine).toBeDefined() expect(spoolLine!.match(/printf/g)).toHaveLength(1) - expect(spoolLine).toContain('"$spool_now" "$payload"') + expect(spoolLine).toContain( + '"$spool_now" "$(spool_json_escape "${orca_agent_process:-}")" "$payload"' + ) }) it('waits for a newline before replaying a complete-looking final record', () => { diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts index 48f4c17ccb3d..2d52ee005e4a 100644 --- a/src/main/agent-trust-presets.ts +++ b/src/main/agent-trust-presets.ts @@ -170,9 +170,9 @@ export function markCodexProjectTrusted( ): Promise { // Why: Codex checks the cwd's own entry before the repo root, so no git-layout logic is needed. const absPath = canonicalize(workspacePath) - // Why (#16441): hook installs now await a codex app-server grant, so an - // unqueued write here can land inside their capture->restore window and be - // reverted. Same runtime-before-system lock order the installer takes. + // Why (#16441): hook installs read and rewrite these files across awaits, so + // an unqueued write here could land between their read and their write and be + // lost. Same runtime-before-system lock order the installer takes. const write = configFiles.reduceRight<() => Promise>( (inner, configFile) => () => runExclusivelyForCodexTrustConfig(configFile, inner), async () => { diff --git a/src/main/agent-trust-write-deadline.ts b/src/main/agent-trust-write-deadline.ts index 450966a67de2..7b314db06737 100644 --- a/src/main/agent-trust-write-deadline.ts +++ b/src/main/agent-trust-write-deadline.ts @@ -3,8 +3,9 @@ import type { AgentTrustPreset } from './agent-trust-presets' /** * Why: callers hold launch admission on a trust write, but the write is not * self-bounding. The local Codex writer queues on the per-`config.toml` lane - * shared with hook installs and app-server trust grants (up to 10s native / - * 30s WSL each, with no cap on how many are already queued). Without a cap the + * shared with managed-home hook installs, which hold it across their inline + * trust grant (up to 10s native each, with no cap on how many are already + * queued); the real-home background approval holds none. Without a cap the * user clicks "start agent" and nothing happens, with no error. * * 20s covers a native grant session holding the lane with headroom. Past that diff --git a/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts b/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts index d78130a119fa..da6703667d17 100644 --- a/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts +++ b/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { ORCAD_BUN_RELEASE_ASSETS } from '../../shared/orcad-bun-runtime' +import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin' import { runProcess } from '../../shared/child-process/run-process' import type * as preparationModule from './opencode-wsl-runtime-preparation' @@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => ({ exists: vi.fn(() => true), run: vi.fn(), running: vi.fn(async (paths: readonly string[]) => [...paths]), - download: vi.fn(async () => 'C:\\cache\\bun'), + download: vi.fn(async () => 'C:\\cache\\node-v24.21.0-linux-arm64-musl.tar.gz'), bundles: vi.fn((platform: string) => [`C:/relay/${platform}`]) })) vi.mock('node:fs', () => ({ existsSync: mocks.exists })) @@ -24,8 +24,8 @@ vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: mocks.run })) vi.mock('../wsl-running-path-filter', () => ({ filterPathsToRunningWslDistrosAsync: mocks.running })) -vi.mock('../ssh/orcad-bun-runtime-materializer', () => ({ - materializeCachedOrcadBunRuntime: mocks.download +vi.mock('../ssh/pinned-runtime-materializer', () => ({ + materializeNodeRuntimeArchive: mocks.download })) vi.mock('../ssh/relay-bundle-paths', () => ({ relayBundleCandidates: mocks.bundles })) @@ -84,7 +84,7 @@ describe('WSL SQLite runtime preparation', () => { expect.objectContaining({ program: 'node', loginPath: 'preferred', - args: ['-e', expect.stringContaining("require('node:sqlite')")] + args: ['-e', expect.stringMatching(/require\('node:sqlite'\).*backup/)] }) ) expect(mocks.bundles.mock.calls.map(([platform]) => platform)).toEqual([ @@ -131,8 +131,41 @@ describe('WSL SQLite runtime preparation', () => { await prepared() }) - it('falls back to the pinned proxy runtime, verifies the guest stage, and preserves literal argv', async () => { - const expected = ORCAD_BUN_RELEASE_ASSETS['linux-arm64-musl'].executableSha256 + it('rejects a guest Node without the SyncDatabase surface and tolerates its stderr warning', async () => { + await prepared() + const probe = mocks.run.mock.calls.find(([spec]) => spec.program === 'node')?.[0] + expect(probe?.args[0]).toBe('-e') + const directory = await mkdtemp(join(tmpdir(), 'orca-wsl-sqlite-probe-')) + const runProbe = (preload: string) => + runProcess({ + program: process.execPath, + args: probe.args, + timeoutMs: 10_000, + env: { ...process.env, NODE_OPTIONS: `--require ${JSON.stringify(preload)}` } + }) + try { + // Node 22.13-22.15: DatabaseSync ships, the backup export does not. + const withoutBackup = join(directory, 'node-22-13-sqlite.cjs') + await writeFile(withoutBackup, "delete require('node:sqlite').backup") + const rejected = await runProbe(withoutBackup) + expect(rejected.code).not.toBe(0) + expect(rejected.stdout).toBe('') + const warning = join(directory, 'experimental-warning.cjs') + await writeFile( + warning, + "process.emitWarning('SQLite is an experimental feature and might change at any time','ExperimentalWarning')" + ) + const admitted = await runProbe(warning) + expect(admitted.code, admitted.stderr).toBe(0) + expect(admitted.stderr).toContain('ExperimentalWarning') + expect(admitted.stdout.trim()).toBe(process.execPath) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('falls back to the pinned Node in the runtimes/ store, verified in the guest, with literal argv', async () => { + const expected = NODE_RUNTIME_ASSETS['linux-arm64-musl'].executableSha256 mocks.run.mockImplementation(async (spec) => { if (spec.script?.startsWith('data=')) { return success('present') @@ -144,7 +177,9 @@ describe('WSL SQLite runtime preparation', () => { return success('aarch64') } if (spec.program === 'wslpath') { - return success('/mnt/c/reader $literal.cjs') + return success( + `/mnt/c/${spec.args.at(-1).includes('.tar.gz') ? 'node $literal.tar.gz' : 'reader $literal.cjs'}` + ) } if (spec.script?.startsWith('getconf')) { return success('musl libc') @@ -152,7 +187,12 @@ describe('WSL SQLite runtime preparation', () => { if (spec.script?.startsWith('printf')) { return success('/home/ada $literal') } - return success('') + if (spec.script?.includes('ORCA_NODE_RUNTIME_EXTRACT_FAILED')) { + return success('ORCA_NODE_RUNTIME_READY') + } + return success( + spec.script?.includes('ORCA_NODE_RUNTIME_MISSING') ? 'ORCA_NODE_RUNTIME_MISSING' : '' + ) }) const result = await prepared() expect(mocks.download).toHaveBeenCalledWith( @@ -160,18 +200,20 @@ describe('WSL SQLite runtime preparation', () => { expect.any(String), expect.objectContaining({ signal: expect.any(AbortSignal) }) ) - expect(result[0]?.executable).toBe( - `/home/ada $literal/.cache/orca/vault-sqlite/${expected}/bun` - ) - expect(mocks.run).toHaveBeenCalledWith( - expect.objectContaining({ - args: ['/mnt/c/reader $literal.cjs', result[0]?.executable, expected], - script: expect.stringContaining('actual=$(sha256sum -- "$stage")') - }) - ) + expect(result[0]).toEqual({ + distro: 'Ubuntu', + executable: `/home/ada $literal/.cache/orca/runtimes/node-${expected}/bin/node`, + readerPath: '/mnt/c/reader $literal.cjs' + }) + const install = mocks.run.mock.calls + .map(([spec]) => spec) + .find((spec) => spec.script?.includes('ORCA_NODE_RUNTIME_EXTRACT_FAILED')) + expect(install).toMatchObject({ args: ['/mnt/c/node $literal.tar.gz'], loginPath: 'none' }) + expect(install.script).toContain(expected) + expect(install.timeoutMs).toBeLessThanOrEqual(120_000) expect( mocks.run.mock.calls.every( - ([spec]) => spec.timeoutMs <= 15_000 && spec.maxOutputBytes === 16_384 + ([spec]) => (spec === install || spec.timeoutMs <= 15_000) && spec.maxOutputBytes === 16_384 ) ).toBe(true) }) @@ -200,10 +242,11 @@ describe('WSL SQLite runtime preparation', () => { }) mocks.download.mockImplementationOnce(async () => { mocks.running.mockResolvedValue([]) - return 'C:\\cache\\bun' + return 'C:\\cache\\node.tar.gz' }) await prepare([home]) await vi.waitFor(async () => expect((await prepare([home]))[0]?.error).toContain('not running')) + expect(mocks.download).toHaveBeenCalledOnce() expect(mocks.run.mock.calls.some(([spec]) => spec.script?.includes('mv -f'))).toBe(false) }) diff --git a/src/main/ai-vault/opencode-wsl-runtime-preparation.ts b/src/main/ai-vault/opencode-wsl-runtime-preparation.ts index c9bc81fff775..1f242772acd3 100644 --- a/src/main/ai-vault/opencode-wsl-runtime-preparation.ts +++ b/src/main/ai-vault/opencode-wsl-runtime-preparation.ts @@ -1,21 +1,31 @@ +import { randomBytes } from 'node:crypto' import { existsSync } from 'node:fs' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' -import { ORCAD_BUN_RELEASE_ASSETS, type OrcadBunTarget } from '../../shared/orcad-bun-runtime' +import type { ServerTarget } from '../../shared/node-runtime-pin' import { RELAY_OPENCODE_SQLITE_READER_FILENAME } from '../../shared/relay-artifacts' import { parseWslUncPath, toWindowsWslUncPath } from '../../shared/wsl-paths' +import { NODE_SQLITE_READER_API_SOURCE } from '../sqlite/node-sqlite-reader-api' import { relayBundleCandidates } from '../ssh/relay-bundle-paths' -import { materializeCachedOrcadBunRuntime } from '../ssh/orcad-bun-runtime-materializer' +import { materializeNodeRuntimeArchive } from '../ssh/pinned-runtime-materializer' import { parseOrcadLinuxLibc } from '../ssh/orcad-deployment-target' +import { + installNodeRuntimeFromHostArchiveCommand, + nodeRuntimeStoreDir, + posixNodeRuntimeExecutable, + probeRemoteNodeRuntimeCommand, + REMOTE_NODE_RUNTIME_READY +} from '../ssh/orcad-remote-node-runtime' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' import { runWslProcess, type WslSpec } from '../wsl/wsl-runner' import { filterPathsToRunningWslDistrosAsync } from '../wsl-running-path-filter' import type { OpenCodeWslRuntime } from './session-scanner-opencode-wsl-runtime' const preparation = new Map() -const downloads = new Map>() +const downloads = new Map>() const PREPARATION_TIMEOUT_MS = 180_000 -const SQLITE_PROBE = `const db=new (require('node:sqlite').DatabaseSync)(':memory:');db.prepare('SELECT 1').get();db.close();process.stdout.write(process.execPath)` +const SQLITE_PROBE = `const sqlite=require('node:sqlite');if(!(${NODE_SQLITE_READER_API_SOURCE})(sqlite))throw Error('SQLite reader API missing');const db=new sqlite.DatabaseSync(':memory:');db.prepare('SELECT 1').get();db.close();process.stdout.write(process.execPath)` /** Only running distro roots enter here; a slow first install must not hold up local history. */ export async function prepareOpenCodeWslReaders( @@ -68,7 +78,7 @@ export async function prepareOpenCodeWslReaders( async function prepare(distro: string): Promise { const deadline = Date.now() + PREPARATION_TIMEOUT_MS const signal = AbortSignal.timeout(PREPARATION_TIMEOUT_MS) - const run = async (spec: WslSpec): Promise => { + const run = async (spec: WslSpec, timeoutMs = 15_000): Promise => { signal.throwIfAborted() const running = await waitForPromiseWithSignal( filterPathsToRunningWslDistrosAsync([toWindowsWslUncPath('/', distro)], { @@ -83,7 +93,7 @@ async function prepare(distro: string): Promise { const result = await runWslProcess({ ...spec, distro, - timeoutMs: Math.max(1, Math.min(15_000, deadline - Date.now())), + timeoutMs: Math.max(1, Math.min(timeoutMs, deadline - Date.now())), maxOutputBytes: 16 * 1024 }) if (result.code !== 0 || result.timedOut) { @@ -142,49 +152,50 @@ async function prepare(distro: string): Promise { }) ) const target = `linux-${arch === 'x86_64' ? 'x64' : 'arm64'}-${libc}` as const - const expected = ORCAD_BUN_RELEASE_ASSETS[target].executableSha256 const home = await run({ script: 'printf %s "$HOME"', loginPath: 'none' }) if (!home.startsWith('/')) { throw new Error('WSL did not provide an absolute home directory.') } - executable = `${home}/.cache/orca/vault-sqlite/${expected}/bun` - const present = await run({ - script: 'if [ -x "$1" ]; then sha256sum -- "$1"; fi', - args: [executable], + // Same layout and checks as an SSH host's store; a legacy vault-sqlite/ Bun is left alone. + const host = getRemoteHostPlatform(arch === 'x86_64' ? 'linux-x64' : 'linux-arm64') + const runtimeDir = nodeRuntimeStoreDir(host, `${home}/.cache/orca`, target) + executable = posixNodeRuntimeExecutable(host, runtimeDir) + const probe = await run({ + script: probeRemoteNodeRuntimeCommand(host, runtimeDir, target), loginPath: 'none' }) - if (!present.startsWith(`${expected} `)) { + if (probe !== REMOTE_NODE_RUNTIME_READY) { let download = downloads.get(target) if (!download) { - download = materializeCachedOrcadBunRuntime( + download = materializeNodeRuntimeArchive( target, join(app.getPath('userData'), 'orcad-artifacts'), - { - signal: AbortSignal.timeout(PREPARATION_TIMEOUT_MS) - } + { signal: AbortSignal.timeout(PREPARATION_TIMEOUT_MS) } ).finally(() => downloads.delete(target)) downloads.set(target, download) } - const localRuntime = await waitForPromiseWithSignal(download, signal) + const localArchive = await waitForPromiseWithSignal(download, signal) const source = await run({ program: 'wslpath', - args: ['-a', '-u', localRuntime], - loginPath: 'none' - }) - await run({ - script: [ - 'set -eu; umask 077', - 'mkdir -p -- "${2%/*}"', - 'stage=$(mktemp "${2}.upload.XXXXXX")', - 'trap \'rm -f -- "$stage"\' EXIT', - 'cp -- "$1" "$stage"', - 'actual=$(sha256sum -- "$stage"); [ "${actual%% *}" = "$3" ]', - 'chmod 700 "$stage"', - 'mv -f -- "$stage" "$2"' - ].join('\n'), - args: [source, executable, expected], + args: ['-a', '-u', localArchive], loginPath: 'none' }) + const promoted = await run( + { + script: installNodeRuntimeFromHostArchiveCommand(host, { + runtimeDir, + archive: basename(localArchive), + target, + token: randomBytes(8).toString('hex') + }), + args: [source], + loginPath: 'none' + }, + 120_000 + ) + if (promoted.split('\n').at(-1) !== REMOTE_NODE_RUNTIME_READY) { + throw new Error('WSL did not verify the pinned Node runtime.') + } } } return { distro, executable, readerPath } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-process.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-process.test.ts index 914028857210..5740baf1de8e 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-process.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-process.test.ts @@ -1,12 +1,10 @@ import { build } from 'esbuild' import { existsSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' +import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import * as processRunner from '../../shared/child-process/run-process' import { runProcess, spawnProcess } from '../../shared/child-process/run-process' -import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' -import { orcadBunRuntimeFilename } from '../../shared/orcad-artifacts' import SyncDatabase from '../sqlite/sync-database' import { appendTurns, writeOpenCodeSqliteDatabase } from './session-scanner-opencode-sqlite-fixture' import { createOpenCodeSqliteProcessClient } from './session-scanner-opencode-sqlite-process-client' @@ -16,8 +14,7 @@ import { } from './session-scanner-opencode-sqlite-process-framing' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' -const bun = - process.env.BUN_EXECUTABLE ?? resolve('out/orcad', orcadBunRuntimeFilename(process.platform)) +const executable = process.execPath const directory = mkdtempSync(join(tmpdir(), 'orca-opencode-process-')) const entry = join(directory, 'reader.cjs') const dbPath = join(directory, 'opencode.db') @@ -32,7 +29,6 @@ beforeAll(async () => { platform: 'node', format: 'cjs', target: 'node22', - external: ['bun:sqlite'], outfile: entry, logLevel: 'silent' }) @@ -57,129 +53,119 @@ afterAll(() => { rmSync(directory, { recursive: true, force: true }) }) -for (const [runtime, executable] of [ - ['Node', process.execPath], - ['Bun', bun] -]) { - describe.skipIf(!existsSync(executable))(`OpenCode SQLite process under ${runtime}`, () => { - it('uses the deployed runtime and reads live WAL, full prompts and captures in one persistent child', async () => { - if (runtime === 'Bun') { - expect((await runProcess({ program: executable, args: ['--version'] })).stdout.trim()).toBe( - ORCAD_BUN_VERSION - ) - } - expect(existsSync(`${dbPath}-wal`)).toBe(true) - const spawn = vi.spyOn(processRunner, 'spawnProcess') - const client = createOpenCodeSqliteProcessClient({ - executable, +describe('OpenCode SQLite process under Node', () => { + it('uses the deployed runtime and reads live WAL, full prompts and captures in one persistent child', async () => { + expect(existsSync(`${dbPath}-wal`)).toBe(true) + const spawn = vi.spyOn(processRunner, 'spawnProcess') + const client = createOpenCodeSqliteProcessClient({ + executable, + args: [entry], + cwd: directory, + beforeSpawn: async () => {} + }) + try { + const issues: AiVaultScanIssue[] = [] + const listed = await client.list({ dbPaths: [dbPath], limit: 5, issues }) + expect(issues).toEqual([]) + expect(listed).toHaveLength(1) + expect(await client.list({ dbPaths: [dbPath], limit: Infinity, issues })).toHaveLength(1) + const args = { dbPath, sessionId: 'wal-session', platform: process.platform } + expect((await client.parse(args))?.firstUserPrompt).toBeUndefined() + expect((await client.parse({ ...args, fullFirstUserPrompt: true }))?.firstUserPrompt).toBe( + prompt.trim() + ) + expect((await client.capture(args)).messages).toEqual([ + expect.objectContaining({ role: 'user', text: prompt }) + ]) + expect(spawn).toHaveBeenCalledOnce() + } finally { + client.dispose() + spawn.mockRestore() + } + }) + + it('fails oversized query responses without truncating a transcript and remains usable', async () => { + const client = createOpenCodeSqliteProcessClient({ executable, args: [entry] }) + try { + await expect( + client.capture({ + dbPath: oversizedPath, + sessionId: 'oversized', + platform: process.platform + }) + ).rejects.toThrow('response exceeds its byte limit') + expect( + await client.parse({ dbPath, sessionId: 'wal-session', platform: process.platform }) + ).not.toBeNull() + } finally { + client.dispose() + } + }) + + it('exits on parent EOF even while SQLite is busy, and enforces its own hard deadline', async () => { + const lockedPath = join(directory, 'locked.db') + writeOpenCodeSqliteDatabase(lockedPath, [{ id: 'locked', turns: [] }]) + const lock = new SyncDatabase(lockedPath) + lock.exec('BEGIN EXCLUSIVE') + try { + const request = `${JSON.stringify({ + id: 1, + kind: 'parse', + dbPath: lockedPath, + sessionId: 'locked', + platform: process.platform, + timeoutMs: 100 + })}\n` + const child = spawnProcess({ program: executable, args: [entry] }) + child.stderr.resume() + child.stdout.resume() + const exit = new Promise((resolveExit) => child.once('exit', resolveExit)) + child.stdin.on('error', () => {}) + child.stdin.write(request) + expect(await exit).toBe(124) + + const eof = await runProcess({ + program: executable, args: [entry], - cwd: directory, - beforeSpawn: async () => {} + input: request, + timeoutMs: 2_000 }) - try { - const issues: AiVaultScanIssue[] = [] - const listed = await client.list({ dbPaths: [dbPath], limit: 5, issues }) - expect(issues).toEqual([]) - expect(listed).toHaveLength(1) - expect(await client.list({ dbPaths: [dbPath], limit: Infinity, issues })).toHaveLength(1) - const args = { dbPath, sessionId: 'wal-session', platform: process.platform } - expect((await client.parse(args))?.firstUserPrompt).toBeUndefined() - expect((await client.parse({ ...args, fullFirstUserPrompt: true }))?.firstUserPrompt).toBe( - prompt.trim() - ) - expect((await client.capture(args)).messages).toEqual([ - expect.objectContaining({ role: 'user', text: prompt }) - ]) - expect(spawn).toHaveBeenCalledOnce() - } finally { - client.dispose() - spawn.mockRestore() - } - }) + expect(eof.timedOut).toBe(false) + expect(eof.code).toBe(0) - it('fails oversized query responses without truncating a transcript and remains usable', async () => { const client = createOpenCodeSqliteProcessClient({ executable, args: [entry] }) + const cancellation = new AbortController() try { - await expect( - client.capture({ - dbPath: oversizedPath, - sessionId: 'oversized', - platform: process.platform - }) - ).rejects.toThrow('response exceeds its byte limit') - expect( - await client.parse({ dbPath, sessionId: 'wal-session', platform: process.platform }) - ).not.toBeNull() - } finally { - client.dispose() - } - }) - - it('exits on parent EOF even while SQLite is busy, and enforces its own hard deadline', async () => { - const lockedPath = join(directory, `locked-${runtime}.db`) - writeOpenCodeSqliteDatabase(lockedPath, [{ id: 'locked', turns: [] }]) - const lock = new SyncDatabase(lockedPath) - lock.exec('BEGIN EXCLUSIVE') - try { - const request = `${JSON.stringify({ - id: 1, - kind: 'parse', + const pending = client.parse({ dbPath: lockedPath, sessionId: 'locked', platform: process.platform, - timeoutMs: 100 - })}\n` - const child = spawnProcess({ program: executable, args: [entry] }) - child.stderr.resume() - child.stdout.resume() - const exit = new Promise((resolveExit) => child.once('exit', resolveExit)) - child.stdin.on('error', () => {}) - child.stdin.write(request) - expect(await exit).toBe(124) - - const eof = await runProcess({ - program: executable, - args: [entry], - input: request, - timeoutMs: 2_000 + signal: cancellation.signal }) - expect(eof.timedOut).toBe(false) - expect(eof.code).toBe(0) - - const client = createOpenCodeSqliteProcessClient({ executable, args: [entry] }) - const cancellation = new AbortController() - try { - const pending = client.parse({ - dbPath: lockedPath, - sessionId: 'locked', - platform: process.platform, - signal: cancellation.signal - }) - const rejected = expect(pending).rejects.toThrow('SQL read cancelled') - await new Promise((resolveDelay) => setTimeout(resolveDelay, 100)) - cancellation.abort(new Error('SQL read cancelled')) - await rejected - } finally { - client.dispose() - } + const rejected = expect(pending).rejects.toThrow('SQL read cancelled') + await new Promise((resolveDelay) => setTimeout(resolveDelay, 100)) + cancellation.abort(new Error('SQL read cancelled')) + await rejected } finally { - lock.exec('ROLLBACK') - lock.close() + client.dispose() } - }) + } finally { + lock.exec('ROLLBACK') + lock.close() + } + }) - it('rejects oversized request frames before running a query', async () => { - const result = await runProcess({ - program: executable, - args: [entry], - input: 'x'.repeat(OPENCODE_SQLITE_REQUEST_MAX_BYTES + 1), - timeoutMs: 2_000 - }) - expect(result.code).toBe(1) - expect(result.stdout).toBe('') + it('rejects oversized request frames before running a query', async () => { + const result = await runProcess({ + program: executable, + args: [entry], + input: 'x'.repeat(OPENCODE_SQLITE_REQUEST_MAX_BYTES + 1), + timeoutMs: 2_000 }) + expect(result.code).toBe(1) + expect(result.stdout).toBe('') }) -} +}) describe('OpenCode SQLite process retirement', () => { const args = { dbPath, sessionId: 'wal-session', platform: process.platform } diff --git a/src/main/azure-devops/repository-ref.test.ts b/src/main/azure-devops/repository-ref.test.ts index 4a2a1be1219b..8b8a98f625b3 100644 --- a/src/main/azure-devops/repository-ref.test.ts +++ b/src/main/azure-devops/repository-ref.test.ts @@ -177,24 +177,4 @@ describe('parseAzureDevOpsRepoRef', () => { expect(_getAzureDevOpsRepoRefCacheSize()).toBe(512) }) - - it('does not cache transient SSH provider failures as unsupported repos', async () => { - sshExecMock.mockRejectedValueOnce(new Error('connection closed')).mockResolvedValueOnce({ - stdout: 'git@ssh.dev.azure.com:v3/acme/Project/repo\n', - stderr: '' - }) - registerSshGitProvider('conn-1', { exec: sshExecMock } as never) - - await expect(getAzureDevOpsRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toBeNull() - await expect(getAzureDevOpsRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toEqual({ - host: 'dev.azure.com', - organization: 'acme', - project: 'Project', - repository: 'repo', - apiBaseUrl: 'https://dev.azure.com/acme/Project', - webBaseUrl: 'https://dev.azure.com/acme/Project/_git/repo' - }) - - expect(sshExecMock).toHaveBeenCalledTimes(2) - }) }) diff --git a/src/main/bitbucket/repository-ref.test.ts b/src/main/bitbucket/repository-ref.test.ts index 603e4fb8a6c5..42aa0454c1fc 100644 --- a/src/main/bitbucket/repository-ref.test.ts +++ b/src/main/bitbucket/repository-ref.test.ts @@ -96,42 +96,6 @@ describe('Bitbucket repository refs', () => { }) }) - it('keeps local host and local WSL repository-ref cache entries separate', async () => { - gitExecFileAsyncMock - .mockResolvedValueOnce({ - stdout: 'git@bitbucket.org:host/project.git\n', - stderr: '' - }) - .mockResolvedValueOnce({ - stdout: 'git@bitbucket.org:wsl/project.git\n', - stderr: '' - }) - - await expect(getBitbucketRepoRef('/repo')).resolves.toEqual({ - workspace: 'host', - repoSlug: 'project' - }) - await expect(getBitbucketRepoRef('/repo', null, { wslDistro: 'Ubuntu' })).resolves.toEqual({ - workspace: 'wsl', - repoSlug: 'project' - }) - await expect(getBitbucketRepoRef('/repo', null, { wslDistro: 'Ubuntu' })).resolves.toEqual({ - workspace: 'wsl', - repoSlug: 'project' - }) - - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) - expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['remote', 'get-url', 'origin'], { - cwd: '/repo', - timeout: REMOTE_URL_PROBE_TIMEOUT_MS - }) - expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['remote', 'get-url', 'origin'], { - cwd: '/repo', - wslDistro: 'Ubuntu', - timeout: REMOTE_URL_PROBE_TIMEOUT_MS - }) - }) - it('bounds cached repository refs for distinct repo paths', async () => { gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git@bitbucket.org:team/project.git\n', @@ -162,21 +126,4 @@ describe('Bitbucket repository refs', () => { }) expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) - - it('does not cache transient SSH provider failures as unsupported repos', async () => { - sshExecMock.mockRejectedValueOnce(new Error('connection closed')).mockResolvedValueOnce({ - stdout: 'git@bitbucket.org:remote/project.git\n', - stderr: '' - }) - registerSshGitProvider('conn-1', { exec: sshExecMock } as never) - - await expect(getBitbucketRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toBeNull() - await expect(getBitbucketRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toEqual({ - workspace: 'remote', - repoSlug: 'project' - }) - - expect(sshExecMock).toHaveBeenCalledTimes(2) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) }) diff --git a/src/main/browser/agent-browser-bridge-command-transport.test.ts b/src/main/browser/agent-browser-bridge-command-transport.test.ts index e2bbef162356..c4b274662aa2 100644 --- a/src/main/browser/agent-browser-bridge-command-transport.test.ts +++ b/src/main/browser/agent-browser-bridge-command-transport.test.ts @@ -53,6 +53,7 @@ vi.mock('./cdp-bridge', () => ({ import { AgentBrowserBridge } from './agent-browser-bridge' import { AGENT_BROWSER_IDLE_TIMEOUT_MS } from './agent-browser-process-environment' import { + createFakeAgentBrowserChild, createSucceedWith, mockBrowserManager, mockWebContents, @@ -168,7 +169,7 @@ describe('AgentBrowserBridge', () => { it('maps in-flight CDP discovery failures to tab not found after the session disappears', async () => { let releaseSnapshot: (() => void) | null = null - const activeChild = { kill: vi.fn() } + const activeChild = createFakeAgentBrowserChild({ kill: vi.fn() }) execFileMock.mockImplementation( (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { if (args.includes('snapshot')) { @@ -178,7 +179,7 @@ describe('AgentBrowserBridge', () => { return activeChild } cb(null, JSON.stringify({ success: true, data: null }), '') - return { kill: vi.fn() } + return createFakeAgentBrowserChild({ kill: vi.fn() }) } ) diff --git a/src/main/browser/agent-browser-bridge-navigation.test.ts b/src/main/browser/agent-browser-bridge-navigation.test.ts index c0dcc2c0d8ab..8b9218afec62 100644 --- a/src/main/browser/agent-browser-bridge-navigation.test.ts +++ b/src/main/browser/agent-browser-bridge-navigation.test.ts @@ -52,6 +52,7 @@ vi.mock('./cdp-bridge', () => ({ import { AgentBrowserBridge } from './agent-browser-bridge' import { + createFakeAgentBrowserChild, createSucceedWith, mockBrowserManager, mockWebContents, @@ -409,7 +410,7 @@ describe('AgentBrowserBridge', () => { '' ) } - return { kill: vi.fn() } + return createFakeAgentBrowserChild({ kill: vi.fn() }) } ) diff --git a/src/main/browser/agent-browser-bridge-raw-process.test.ts b/src/main/browser/agent-browser-bridge-raw-process.test.ts new file mode 100644 index 000000000000..7fbe09884ff6 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-raw-process.test.ts @@ -0,0 +1,54 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { userDataPath } = vi.hoisted(() => ({ userDataPath: { current: '' } })) + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn(() => userDataPath.current), + getAppPath: vi.fn(() => userDataPath.current), + isPackaged: false + } +})) + +import { AgentBrowserBridge } from './agent-browser-bridge' +import { mockBrowserManager } from './agent-browser-bridge-test-harness' + +const PAYLOAD_BYTES = 5 * 1024 * 1024 + +// Why: reproduces vercel-labs/agent-browser#1407 on every platform: a detached grandchild keeps +// the helper's stdout/stderr pipes open after the helper itself has written its JSON and exited. +const HELPER_WITH_LINGERING_DAEMON = ` +const { spawn } = require('node:child_process') +const daemon = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 20000)'], { + stdio: 'inherit', + detached: true +}) +daemon.unref() +process.stdout.write(JSON.stringify({ daemonPid: daemon.pid, payload: 'x'.repeat(${PAYLOAD_BYTES}) })) +` + +describe('AgentBrowserBridge raw helper process', () => { + afterEach(() => { + rmSync(userDataPath.current, { recursive: true, force: true }) + }) + + it('settles on helper exit with full output while a descendant still holds the pipes', async () => { + userDataPath.current = mkdtempSync(join(tmpdir(), 'orca-agent-browser-raw-')) + const bridge = new AgentBrowserBridge(mockBrowserManager()) + Object.defineProperty(bridge, 'agentBrowserBin', { value: process.execPath }) + + const stdout = await bridge['runAgentBrowserRaw']('orca-tab-raw', [ + '-e', + HELPER_WITH_LINGERING_DAEMON + ]) + const output: { daemonPid: number; payload: string } = JSON.parse(stdout) + try { + expect(output.payload).toHaveLength(PAYLOAD_BYTES) + } finally { + process.kill(output.daemonPid) + } + }, 10_000) +}) diff --git a/src/main/browser/agent-browser-bridge-raw-process.ts b/src/main/browser/agent-browser-bridge-raw-process.ts index bdc5aadbbd93..49663d260c27 100644 --- a/src/main/browser/agent-browser-bridge-raw-process.ts +++ b/src/main/browser/agent-browser-bridge-raw-process.ts @@ -3,6 +3,7 @@ import { BrowserError } from './cdp-bridge' import { classifyErrorCode } from './agent-browser-bridge-process' import { AgentBrowserBridgeExecution } from './agent-browser-bridge-execution' import { + AGENT_BROWSER_EXIT_DRAIN_MS, CONSECUTIVE_TIMEOUT_LIMIT, EXEC_TIMEOUT_MS, type AgentBrowserExecOptions @@ -95,6 +96,14 @@ export abstract class AgentBrowserBridgeRawProcess extends AgentBrowserBridgeExe resolve(stdout) } ) + // Why: a Windows daemon inherits the helper's stdio pipes (vercel-labs/agent-browser#1407), so + // EOF never arrives; after exit, drain briefly then release the pipes so execFile settles. + child.once('exit', () => { + setTimeout(() => { + child.stdout?.destroy() + child.stderr?.destroy() + }, AGENT_BROWSER_EXIT_DRAIN_MS) + }) if (session) { session.activeProcess = child } diff --git a/src/main/browser/agent-browser-bridge-session-lifecycle.test.ts b/src/main/browser/agent-browser-bridge-session-lifecycle.test.ts index 431cfd6f0bec..dd8e99272f58 100644 --- a/src/main/browser/agent-browser-bridge-session-lifecycle.test.ts +++ b/src/main/browser/agent-browser-bridge-session-lifecycle.test.ts @@ -60,6 +60,7 @@ vi.mock('./cdp-bridge', () => ({ import { AgentBrowserBridge } from './agent-browser-bridge' import { + createFakeAgentBrowserChild, createSucceedWith, mockBrowserManager, mockWebContents, @@ -126,11 +127,11 @@ describe('AgentBrowserBridge', () => { execFileMock.mockImplementation( (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { if (args.includes('close')) { - return { kill: closeKill } + return createFakeAgentBrowserChild({ kill: closeKill }) } if (args.includes('snapshot')) { cb(null, JSON.stringify({ success: true, data: { snapshot: 'ready' } }), '') - return { kill: vi.fn() } + return createFakeAgentBrowserChild({ kill: vi.fn() }) } throw new Error(`unexpected agent-browser args ${args.join(' ')}`) } @@ -254,10 +255,10 @@ describe('AgentBrowserBridge', () => { releaseStaleClose = () => { cb(null, JSON.stringify({ success: true, data: null }), '') } - return { kill: vi.fn() } + return createFakeAgentBrowserChild({ kill: vi.fn() }) } cb(null, JSON.stringify({ success: true, data: null }), '') - return { kill: vi.fn() } + return createFakeAgentBrowserChild({ kill: vi.fn() }) } ) @@ -299,11 +300,11 @@ describe('AgentBrowserBridge', () => { const killedError = Object.assign(new Error('killed'), { killed: true }) let resolveRunningCommand: (() => void) | null = null - const activeChild = { + const activeChild = createFakeAgentBrowserChild({ kill: vi.fn(() => { resolveRunningCommand?.() }) - } + }) execFileMock.mockImplementation( (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { @@ -313,10 +314,10 @@ describe('AgentBrowserBridge', () => { } if (args.includes('close')) { cb(null, JSON.stringify({ success: true, data: null }), '') - return { kill: vi.fn() } + return createFakeAgentBrowserChild({ kill: vi.fn() }) } cb(null, JSON.stringify({ success: true, data: { ok: true } }), '') - return { kill: vi.fn() } + return createFakeAgentBrowserChild({ kill: vi.fn() }) } ) @@ -388,10 +389,10 @@ describe('AgentBrowserBridge', () => { if (args.includes('snapshot')) { finishSnapshot = () => cb(null, JSON.stringify({ success: true, data: { snapshot: 'x' } }), '') - return { kill: vi.fn(() => finishSnapshot?.()) } + return createFakeAgentBrowserChild({ kill: vi.fn(() => finishSnapshot?.()) }) } cb(null, JSON.stringify({ success: true, data: null }), '') - return { kill: vi.fn() } + return createFakeAgentBrowserChild({ kill: vi.fn() }) } ) @@ -619,7 +620,7 @@ describe('AgentBrowserBridge', () => { activeRetirements-- cb(null, JSON.stringify({ success: true, data: null }), '') }) - return { kill: vi.fn() } + return createFakeAgentBrowserChild({ kill: vi.fn() }) } ) diff --git a/src/main/browser/agent-browser-bridge-tab-routing.test.ts b/src/main/browser/agent-browser-bridge-tab-routing.test.ts index d4d5af32b831..d5bed5ea9f82 100644 --- a/src/main/browser/agent-browser-bridge-tab-routing.test.ts +++ b/src/main/browser/agent-browser-bridge-tab-routing.test.ts @@ -52,6 +52,7 @@ vi.mock('./cdp-bridge', () => ({ import { AgentBrowserBridge } from './agent-browser-bridge' import { + createFakeAgentBrowserChild, createSucceedWith, mockBrowserManager, mockWebContents, @@ -398,15 +399,14 @@ describe('AgentBrowserBridge', () => { (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { if (args.includes('close')) { cb(null, JSON.stringify({ success: true, data: null }), '') - return - } - if (args.includes('snapshot')) { + } else if (args.includes('snapshot')) { releaseSnapshot = () => { cb(null, JSON.stringify({ success: true, data: { snapshot: 'tree' } }), '') } - return + } else { + cb(null, JSON.stringify({ success: true, data: { ok: true } }), '') } - cb(null, JSON.stringify({ success: true, data: { ok: true } }), '') + return createFakeAgentBrowserChild({}) } ) diff --git a/src/main/browser/agent-browser-bridge-test-harness.ts b/src/main/browser/agent-browser-bridge-test-harness.ts index 34e381838b2a..583d01c5731d 100644 --- a/src/main/browser/agent-browser-bridge-test-harness.ts +++ b/src/main/browser/agent-browser-bridge-test-harness.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'node:events' import { vi, type Mock } from 'vitest' import type { AgentBrowserBridge } from './agent-browser-bridge' import type { BrowserManager } from './browser-manager' @@ -111,14 +112,19 @@ export function overrideBridgeWebContentsLookup( }) } +// Why: runAgentBrowserRaw listens for the child's exit/close, so execFile fakes must be emitters. +export function createFakeAgentBrowserChild(fields: T): EventEmitter & T { + return Object.assign(new EventEmitter(), fields) +} + export function createSucceedWith(execFileMock: Mock, stdinWrites: string[]) { return function succeedWith(data: unknown): void { execFileMock.mockImplementation( (_bin: string, _args: string[], _opts: unknown, cb: ExecFileCallback) => { cb(null, JSON.stringify({ success: true, data }), '') - return { + return createFakeAgentBrowserChild({ stdin: { on: vi.fn(), end: (text: string) => stdinWrites.push(text) } - } + }) } ) } diff --git a/src/main/browser/agent-browser-bridge-text-input.test.ts b/src/main/browser/agent-browser-bridge-text-input.test.ts index 2a772b04b31c..93beafbeb912 100644 --- a/src/main/browser/agent-browser-bridge-text-input.test.ts +++ b/src/main/browser/agent-browser-bridge-text-input.test.ts @@ -60,6 +60,7 @@ import { CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR } from '../../shared/clipboard-text' import { + createFakeAgentBrowserChild, createSucceedWith, mockBrowserManager, mockWebContents, @@ -80,9 +81,9 @@ function succeedForContentEditable(data: unknown = { ok: true }): void { ? { value: 'true' } : data cb(null, JSON.stringify({ success: true, data: result }), '') - return { + return createFakeAgentBrowserChild({ stdin: { on: vi.fn(), end: (text: string) => stdinWrites.push(text) } - } + }) } ) } diff --git a/src/main/browser/agent-browser-bridge-types.ts b/src/main/browser/agent-browser-bridge-types.ts index 5d6e10e30785..2d4236afdbd8 100644 --- a/src/main/browser/agent-browser-bridge-types.ts +++ b/src/main/browser/agent-browser-bridge-types.ts @@ -7,6 +7,7 @@ export const EXEC_TIMEOUT_MS = 90_000 export const CONSECUTIVE_TIMEOUT_LIMIT = 3 export const WAIT_PROCESS_TIMEOUT_GRACE_MS = 1_000 export const STALE_SESSION_CLOSE_TIMEOUT_MS = 3_000 +export const AGENT_BROWSER_EXIT_DRAIN_MS = 500 // Why separate from EXEC_TIMEOUT_MS: a close is a member of the 20s will-quit barrier and must finish well inside it. export const AGENT_BROWSER_CLEANUP_TIMEOUT_MS = 5_000 export const AGENT_BROWSER_CLEANUP_CONCURRENCY = 4 diff --git a/src/main/browser/browser-guest-grab-shortcuts.ts b/src/main/browser/browser-guest-grab-shortcuts.ts index 4ab6c61a6d42..87ee56f50ad1 100644 --- a/src/main/browser/browser-guest-grab-shortcuts.ts +++ b/src/main/browser/browser-guest-grab-shortcuts.ts @@ -34,6 +34,8 @@ export function setupGrabShortcutForwarding(args: { } if ( + // Why: the renderer toggles the picker per message, so a held chord would flicker it. + input.isAutoRepeat || !keybindingMatchesAction('browser.grabElement', input, process.platform, getKeybindings?.()) ) { return @@ -66,7 +68,7 @@ export function setupGrabShortcutForwarding(args: { if (!renderer) { return } - renderer.send('browser:grabModeToggle', browserTabId) + renderer.send('browser:grabModeToggle', browserTabId, 'copy') }) .catch(() => { // Why: shortcut forwarding is best-effort — guest teardown or a transient executeJavaScript failure must not break normal copy. diff --git a/src/main/browser/browser-guest-shortcut-dispatch.ts b/src/main/browser/browser-guest-shortcut-dispatch.ts index 7c04b020dcbe..84a36e30b724 100644 --- a/src/main/browser/browser-guest-shortcut-dispatch.ts +++ b/src/main/browser/browser-guest-shortcut-dispatch.ts @@ -150,6 +150,12 @@ export function forwardGuestShortcutInput( const browserWorkspaceId = resolveWorkspaceId?.(browserTabId) || undefined const target: BrowserFindTarget = { browserPageId: browserTabId, browserWorkspaceId } renderer.send('ui:findInBrowserPage', target) + } else if ( + // Why: client-hosted guests register no workspace and their pane has no annotate listener, so leave the chord to the page. + resolveWorkspaceId?.(browserTabId) && + keybindingMatchesAction('browser.annotateElement', input, process.platform, keybindings) + ) { + renderer.send('browser:grabModeToggle', browserTabId, 'annotate') } else if (keybindingMatchesAction('browser.back', input, process.platform, keybindings)) { // Why: macOS Logitech side-button remaps arrive as history keystrokes, not mouse events; forward so the renderer can goBack(). renderer.send('ui:browserHistoryNavigate', { browserPageId: browserTabId, direction: 'back' }) diff --git a/src/main/browser/browser-manager-grab-shortcuts.test.ts b/src/main/browser/browser-manager-grab-shortcuts.test.ts index 130d81c89d13..4540d44ad88f 100644 --- a/src/main/browser/browser-manager-grab-shortcuts.test.ts +++ b/src/main/browser/browser-manager-grab-shortcuts.test.ts @@ -24,7 +24,11 @@ vi.mock('electron', () => ({ })) import { browserManager } from './browser-manager' -import { resetGrabTestEnvironment } from './browser-manager-grab-test-harness' +import { + GRAB_GUEST_WEB_CONTENTS_ID, + GRAB_RENDERER_WEB_CONTENTS_ID, + resetGrabTestEnvironment +} from './browser-manager-grab-test-harness' const { guestExecuteJavaScriptMock, guestOnMock, rendererSendMock } = grabMocks @@ -61,7 +65,30 @@ describe('browserManager grab operations', () => { await Promise.resolve() expect(preventDefault).toHaveBeenCalledTimes(1) - expect(rendererSendMock).toHaveBeenCalledWith('browser:grabModeToggle', 'tab-1') + expect(rendererSendMock).toHaveBeenCalledWith('browser:grabModeToggle', 'tab-1', 'copy') + }) + + it('ignores a held cmd/ctrl+c so the picker is toggled once', async () => { + const handler = guestOnMock.mock.calls.find( + ([eventName]) => eventName === 'before-input-event' + )?.[1] + + handler?.( + { preventDefault: vi.fn() }, + { + type: 'keyDown', + ...primaryModifier, + shift: false, + alt: false, + key: 'c', + isAutoRepeat: true + } + ) + await Promise.resolve() + await Promise.resolve() + + expect(guestExecuteJavaScriptMock).not.toHaveBeenCalled() + expect(rendererSendMock).not.toHaveBeenCalled() }) it('does not forward cmd/ctrl+c when the guest reports native copy should win', async () => { @@ -145,5 +172,45 @@ describe('browserManager grab operations', () => { expect(preventDefault).toHaveBeenCalledTimes(1) expect(rendererSendMock).toHaveBeenCalledWith('ui:newBrowserTab') }) + + // Default annotate chord: Cmd+Shift+C on macOS, Alt+Shift+N elsewhere. + const annotateChord = + process.platform === 'darwin' + ? { meta: true, control: false, alt: false, shift: true, code: 'KeyC', key: 'C' } + : { meta: false, control: false, alt: true, shift: true, code: 'KeyN', key: 'N' } + + function pressAnnotateChord(): ReturnType { + const preventDefault = vi.fn() + for (const [eventName, handler] of guestOnMock.mock.calls) { + if (eventName === 'before-input-event') { + handler({ preventDefault }, { type: 'keyDown', ...annotateChord }) + } + } + return preventDefault + } + + it('forwards the annotate chord once, skipping the copy-only page probe', () => { + guestOnMock.mockClear() + browserManager.registerGuest({ + browserPageId: 'tab-1', + workspaceId: 'workspace-1', + webContentsId: GRAB_GUEST_WEB_CONTENTS_ID, + rendererWebContentsId: GRAB_RENDERER_WEB_CONTENTS_ID + }) + + const preventDefault = pressAnnotateChord() + + expect(guestExecuteJavaScriptMock).not.toHaveBeenCalled() + expect(preventDefault).toHaveBeenCalledTimes(1) + expect(rendererSendMock.mock.calls).toEqual([['browser:grabModeToggle', 'tab-1', 'annotate']]) + }) + + // A client-hosted guest registers no workspace, and its pane has no annotate to arm. + it('leaves the annotate chord to a page with no local workspace', () => { + const preventDefault = pressAnnotateChord() + + expect(preventDefault).not.toHaveBeenCalled() + expect(rendererSendMock).not.toHaveBeenCalled() + }) }) }) diff --git a/src/main/browser/browser-text-insertion.test.ts b/src/main/browser/browser-text-insertion.test.ts index 3541fca8538e..b1969fd931e3 100644 --- a/src/main/browser/browser-text-insertion.test.ts +++ b/src/main/browser/browser-text-insertion.test.ts @@ -11,13 +11,6 @@ describe('browser text insertion chunking', () => { expect(splitBrowserTextInsertionChunks('hello')).toEqual(['hello']) }) - it('splits by UTF-8 bytes without splitting surrogate pairs', () => { - const chunks = splitBrowserTextInsertionChunks('ab😀cd', 4) - - expect(chunks).toEqual(['ab', '😀', 'cd']) - expect(chunks.join('')).toBe('ab😀cd') - }) - it('iterates insertion chunks lazily without prebuilding the full chunk array', () => { const chunks = iterateBrowserTextInsertionChunks('abcdefghij', 4) diff --git a/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs b/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs index 4f2a09425fd8..799b64546242 100644 --- a/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs +++ b/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs @@ -5,7 +5,8 @@ // // Env contract (set by the test): // ORCA_SDK_CONTRACT_SCENARIO_PATH — JSON file -// { steps: Step[], controlResponses?: { [subtype]: } } where a Step is +// { steps: Step[], controlResponses?: { [subtype]: }, +// controlResponseLeadingFrames?: { [subtype]:
} // ORCA_SDK_CONTRACT_REPORT_PATH — where argv/env observations are written @@ -93,7 +94,7 @@ createInterface({ input: process.stdin }).on('line', (line) => { if (process.env.ORCA_SDK_CONTRACT_IGNORE_CONTROL_REQUESTS) { return } - emit({ + const response = { type: 'control_response', response: { subtype: 'success', @@ -103,7 +104,10 @@ createInterface({ input: process.stdin }).on('line', (line) => { models: [] } } - }) + } + // Frames the CLI wrote just before its answer, in the same write: one read chunk. + const leading = scenario.controlResponseLeadingFrames?.[frame.request?.subtype] ?? [] + process.stdout.write([...leading, response].map((line) => `${JSON.stringify(line)}\n`).join('')) return } if (frame.type === 'control_response') { diff --git a/src/main/claude/claude-api-retry-idle-sweep.test.ts b/src/main/claude/claude-api-retry-idle-sweep.test.ts index 7f3511930292..bac1da60fe73 100644 --- a/src/main/claude/claude-api-retry-idle-sweep.test.ts +++ b/src/main/claude/claude-api-retry-idle-sweep.test.ts @@ -123,7 +123,7 @@ describe('a Claude retrying a refused request', () => { // Once the frames stop, the same clock does let the sweep close it. clock += STRUCTURED_AGENT_SESSION_IDLE_MS await vi.waitFor(() => { - expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict') expect(host.hasSession(SESSION)).toBe(false) }) }) diff --git a/src/main/claude/claude-at-rest-commands.ts b/src/main/claude/claude-at-rest-commands.ts new file mode 100644 index 000000000000..7eaa9c143ed3 --- /dev/null +++ b/src/main/claude/claude-at-rest-commands.ts @@ -0,0 +1,145 @@ +import { join } from 'node:path' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionSlashCommand } from '../../shared/agent-session-wire' +import { structuredSlashCommands } from '../../shared/structured-agent-session-composer' +import { discoverSkills } from '../skills/discovery' +import { scanClaudeCommandFolders } from './claude-command-folder-scan' +import { supportsClaudeStructuredLocation } from './claude-structured-location-support' + +/** How long one scan answers for a workspace and account before the next read scans again. */ +export const CLAUDE_AT_REST_COMMANDS_TTL_MS = 10_000 +/** A scan still unanswered this long no longer holds the next one off, so a hung folder can't + * freeze the menu. At most two run at once, so a folder that stays hung holds no more. */ +const SCAN_OVERDUE_MS = 30_000 +const MAX_SCANS_IN_FLIGHT = 2 + +export type ClaudeAtRestCommandsDeps = { + resolveWorkspacePath: (workspaceId: string) => Promise + now?: () => number + discover?: typeof discoverSkills +} + +type Entry = { + commands?: AgentSessionSlashCommand[] + /** When the kept answer, or the last failure, landed. */ + scannedAt: number + lastStartedAt: number + inFlight: number + /** Numbers each scan, so only an answer newer than the one kept replaces it. */ + started: number + kept: number +} + +/** + * The `/` surface of a Claude chat whose Claude is not running, read from the folders Claude reads + * its own from on the host that runs it: the account's and the workspace's command folders and + * skills, beside the commands the chat's menu always offers. Nothing until the first scan lands; + * `onChange` fires when a scan finds something different. + */ +export class ClaudeAtRestCommandCatalog { + private readonly entries = new Map() + private readonly listeners = new Set<() => void>() + + constructor(private readonly deps: ClaudeAtRestCommandsDeps) {} + + read = (record: AgentSessionRecord): AgentSessionSlashCommand[] | undefined => { + // Another host's folders are only readable there, so this host never answers for them. + if (record.provider !== 'claude' || !supportsClaudeStructuredLocation(record.location)) { + return undefined + } + const key = JSON.stringify([record.location.workspaceId, record.accountHome.path]) + let entry = this.entries.get(key) + if (!entry) { + entry = { + scannedAt: Number.NEGATIVE_INFINITY, + lastStartedAt: Number.NEGATIVE_INFINITY, + inFlight: 0, + started: 0, + kept: 0 + } + this.entries.set(key, entry) + } + const now = this.now() + const due = + entry.inFlight === 0 + ? now - entry.scannedAt >= CLAUDE_AT_REST_COMMANDS_TTL_MS + : entry.inFlight < MAX_SCANS_IN_FLIGHT && now - entry.lastStartedAt >= SCAN_OVERDUE_MS + if (due) { + this.scanInto(entry, record) + } + return entry.commands + } + + onChange = (listener: () => void): (() => void) => { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + private scanInto(entry: Entry, record: AgentSessionRecord): void { + const scan = ++entry.started + entry.lastStartedAt = this.now() + entry.inFlight += 1 + const newest = () => { + if (scan < entry.kept) { + return false + } + entry.kept = scan + entry.scannedAt = this.now() + return true + } + void this.scan(record) + .then( + (commands) => { + if (!newest() || JSON.stringify(commands) === JSON.stringify(entry.commands)) { + return + } + entry.commands = commands + for (const listener of this.listeners) { + listener() + } + }, + (error: unknown) => { + // Waits out the window before trying again, but leaves room for an older scan's answer. + entry.scannedAt = this.now() + console.warn('[claude] reading the at-rest `/` commands failed:', error) + } + ) + .finally(() => { + entry.inFlight -= 1 + }) + } + + private async scan(record: AgentSessionRecord): Promise { + const cwd = await this.deps.resolveWorkspacePath(record.location.workspaceId) + const account = record.accountHome.path + const [custom, discovered] = await Promise.all([ + scanClaudeCommandFolders([join(cwd, '.claude', 'commands'), join(account, 'commands')]), + (this.deps.discover ?? discoverSkills)({ + repos: [], + cwd, + providerRootOverrides: { claude: join(account, 'skills') } + }) + ]) + // What the menu offers a Claude chat at rest without a list, so reading one loses none of it. + const builtIn = structuredSlashCommands(['clear', 'compact'], 'claude').map( + (command): AgentSessionSlashCommand => ({ + name: command.name, + kind: 'command', + ...(command.description ? { description: command.description } : {}) + }) + ) + const names = new Set(builtIn.map((command) => command.name)) + const skills = discovered.skills + .filter((skill) => skill.providers.includes('claude')) + .map((skill): AgentSessionSlashCommand => ({ + name: skill.name, + kind: 'skill', + ...(skill.description ? { description: skill.description } : {}) + })) + return [...builtIn, ...custom.filter((command) => !names.has(command.name)), ...skills] + } + + private now(): number { + return this.deps.now?.() ?? Date.now() + } +} diff --git a/src/main/claude/claude-background-stop-records.test.ts b/src/main/claude/claude-background-stop-records.test.ts new file mode 100644 index 000000000000..0092d9e2f29d --- /dev/null +++ b/src/main/claude/claude-background-stop-records.test.ts @@ -0,0 +1,161 @@ +// A background Stop reaches what the host's child records say runs, the same records that draw the +// strip's Stop and decide whether /clear, /compact and rewind wait. Replayed from a captured frame +// order through the real adapter and a real hook server. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { agentChildWorkStopTargets } from '../../shared/agent-child-work-stop-targets' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { conversationCommandBlocked } from '../native-chat/agent-session-wire/structured-conversation-command-admission' +import type { CapturedFrame } from './claude-captured-frame-builders.test-fixture' +import { RESUMED_BY_MESSAGE } from './claude-captured-task-frames.test-fixture' +import { hostWithParent, parent, producer } from './claude-child-work-producer-harness.test-fixture' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) })) +afterEach(() => vi.restoreAllMocks()) + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: admission reads only the lease and the command/rewind records, all absent here. +const RECORD = { lease: {} } as unknown as AgentSessionRecord + +/** A backgrounded agent the CLI's roster then drops with no ending of its own: the records keep + * it live, the provider tracker does not. */ +async function droppedFromRoster() { + const host = hostWithParent() + const run = await producer(host) + const launch: CapturedFrame[] = RESUMED_BY_MESSAGE.filter((captured) => captured.at < 5_077) + run.replay(launch) + run.replay([ + { at: 6_000, frame: { type: 'system', subtype: 'background_tasks_changed', tasks: [] } } + ]) + const records = () => host.getStructuredChildWorkViews(parent) + const blocked = () => + conversationCommandBlocked( + { + sessionId: 'session-1', + fence: 7, + journal: { snapshot: () => ({ items: [] }), submissions: () => [] }, + adapter: run.adapter + }, + RECORD, + records() + ) + const stopCalls = () => + run.claude.connections[0]!.calls.filter((call) => call.subtype === 'stop_task') + expect(records()).toMatchObject([ + { membership: 'live', stoppable: true, providerId: 'agent-1', kind: 'agent' } + ]) + expect(run.adapter.backgroundTaskState('session-1')).toBeNull() + expect(blocked()?.message).toBe('Stop background tasks before using this command.') + return { run, records, blocked, stopCalls } +} + +describe('a background Stop acts on the host child records', () => { + it("stops the row's own task, which the records keep after the roster dropped it", async () => { + const { run, records, blocked, stopCalls } = await droppedFromRoster() + + const outcome = await run.adapter.stopBackgroundTasks({ + sessionId: 'session-1', + fence: 7, + taskIds: agentChildWorkStopTargets(records(), 'agent-1') + }) + + expect(outcome).toEqual({ cancelled: true }) + expect(stopCalls()).toEqual([{ subtype: 'stop_task', params: { taskId: 'agent-1' } }]) + // The CLI acknowledges a task it no longer runs with no frame: the acknowledgement ends it. + expect(records()).toMatchObject([{ membership: 'settled', outcome: 'cancelled' }]) + expect(blocked()).toBeNull() + }) + + it('stops every task the strip offers a stop when none is named', async () => { + const { run, records, blocked, stopCalls } = await droppedFromRoster() + + const outcome = await run.adapter.stopBackgroundTasks({ + sessionId: 'session-1', + fence: 7, + taskIds: agentChildWorkStopTargets(records()) + }) + + expect(outcome).toEqual({ cancelled: true }) + expect(stopCalls()).toEqual([{ subtype: 'stop_task', params: { taskId: 'agent-1' } }]) + expect(records()).toMatchObject([{ membership: 'settled', outcome: 'cancelled' }]) + expect(blocked()).toBeNull() + }) + + it('leaves a task that ended on its own frame as it ended: its row offers no stop', async () => { + const { run, records, stopCalls } = await droppedFromRoster() + run.replay([ + { + at: 7_000, + frame: { + type: 'system', + subtype: 'task_updated', + task_id: 'agent-1', + patch: { status: 'completed' } + } + } + ]) + + const outcome = await run.adapter.stopBackgroundTasks({ + sessionId: 'session-1', + fence: 7, + taskIds: agentChildWorkStopTargets(records(), 'agent-1') + }) + + // The finished row offers no stop, so nothing reaches the CLI and the outcome stands. + expect(outcome).toEqual({ cancelled: false }) + expect(stopCalls()).toEqual([]) + expect(records()).toMatchObject([{ membership: 'settled', outcome: 'succeeded' }]) + }) + + it('lets the task’s own completion, written before the acknowledgement, replace the Stop', async () => { + const { run, records } = await droppedFromRoster() + await run.adapter.stopBackgroundTasks({ + sessionId: 'session-1', + fence: 7, + taskIds: agentChildWorkStopTargets(records(), 'agent-1') + }) + expect(records()).toMatchObject([{ membership: 'settled', outcome: 'cancelled' }]) + + // The completion the CLI wrote before its answer, which the SDK delivered after it. + run.replay([ + { + at: 7_000, + frame: { + type: 'system', + subtype: 'task_notification', + task_id: 'agent-1', + status: 'completed', + summary: 'Found 3 call sites' + } + } + ]) + expect(records()).toMatchObject([ + { membership: 'settled', outcome: 'succeeded', lastMessage: 'Found 3 call sites' } + ]) + }) + + it('never lets an acknowledged Stop replace an ending the task reported itself', async () => { + const { run, records } = await droppedFromRoster() + // The task's own failure lands; a Stop the user sent before it is acknowledged after. + run.replay([ + { + at: 7_000, + frame: { + type: 'system', + subtype: 'task_notification', + task_id: 'agent-1', + status: 'failed', + summary: 'Exit code 1' + } + } + ]) + await run.adapter.stopBackgroundTasks({ + sessionId: 'session-1', + fence: 7, + taskIds: ['agent-1'] + }) + expect(records()).toMatchObject([ + { membership: 'settled', outcome: 'failed', lastMessage: 'Exit code 1' } + ]) + }) +}) diff --git a/src/main/claude/claude-background-task-terminal-frames.test.ts b/src/main/claude/claude-background-task-terminal-frames.test.ts index 86a2dd2f4099..e44532965d9e 100644 --- a/src/main/claude/claude-background-task-terminal-frames.test.ts +++ b/src/main/claude/claude-background-task-terminal-frames.test.ts @@ -96,25 +96,6 @@ describe('claude background task terminal frames', () => { expect(items).toEqual([]) }) - it('leaves legacy local_subagent tasks to the subagent roster', () => { - const { rows, items } = harness() - rows.observe({ - type: 'system', - subtype: 'task_started', - task_id: 'task-legacy-agent', - task_type: 'local_subagent', - subagent_type: 'explorer' - }) - rows.observe({ - type: 'system', - subtype: 'task_notification', - task_id: 'task-legacy-agent', - status: 'failed', - summary: 'the child failed' - }) - expect(items).toEqual([]) - }) - it('writes nothing for ambient housekeeping the user never asked for', () => { const { rows, items } = harness() rows.observe({ ...START_BASH, task_id: 'ambient-1', ambient: true }) diff --git a/src/main/claude/claude-background-wake-retention.test.ts b/src/main/claude/claude-background-wake-retention.test.ts new file mode 100644 index 000000000000..313f9c5de590 --- /dev/null +++ b/src/main/claude/claude-background-wake-retention.test.ts @@ -0,0 +1,150 @@ +// A finished child leaves the strip and the sidebar at once (both list running children only), and +// its record stays in the host's store until the user's next turn. Claude wakes the agent on its +// own when a background task ends, which opens a root turn nobody sent and retires nothing: +// replayed from captured frame orders through the real adapter, a real hook server and the +// production status feed. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types' +import { structuredRunningChildWork } from '../../shared/agent-child-work-listing' +import type { AgentHookServer } from '../agent-hooks/server' +import type { StructuredAgentSessionJournalProjections } from '../native-chat/agent-session-wire/structured-agent-session-status-journal-projection' +import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' +import type { CapturedFrame } from './claude-captured-frame-builders.test-fixture' +import { backgroundWakeCapture } from './claude-captured-fold-steer-frames.test-fixture' +import { MOVED_TO_BACKGROUND } from './claude-captured-task-frames.test-fixture' +import { hostWithParent, parent, producer } from './claude-child-work-producer-harness.test-fixture' +import { PROVIDER_SESSION_ID } from './claude-structured-session-test-support' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) })) +afterEach(() => vi.restoreAllMocks()) + +type Journal = Parameters[0] + +/** The production feed over what the adapter journaled, with the user's sends as `submissions`. */ +async function wiredSession() { + const host: AgentHookServer = hostWithParent() + const run = await producer(host) + const submissions: AgentJournalSubmission[] = [] + let sequence = 0 + const journal = { + cursor: () => ({ epoch: 'epoch-1', sequence: ++sequence }), + isReadOnly: false, + lastActivityAt: () => 1, + snapshot: () => ({ + items: [...run.journalItems.values()] + .sort((a, b) => a.sequence - b.sequence) + .map((item) => ({ ...item, revision: 1 })), + submissions: submissions.map((submission) => ({ ...submission })) + }) + } + const feed = new StructuredAgentSessionStatusFeed({ + sessions: new Map([ + [ + parent.sessionId, + { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the feed reads only the cursor, read-only flag, activity clock and snapshot served here. + journal: journal as unknown as Journal, + params: { + location: { + executionHostId: parent.executionHostId, + wslDistro: parent.wslDistro, + workspaceId: parent.workspaceId, + workspaceKind: parent.workspaceKind + }, + provider: 'claude' as const + } + } + ] + ]), + getRecord: () => null, + now: () => Date.now(), + statusSink: () => ({ + publish: (summary, subject) => host.ingestStructuredStatus(summary, subject), + forget: (subject) => host.dropStructuredStatus(subject), + publishChildWork: (subject, evidence, provider) => + host.ingestStructuredChildWork(subject, evidence, provider), + readChildWork: (subject) => host.getStructuredChildWorkViews(subject) + }) + }) + const describe = (views: ReturnType) => + views.map((view) => `${view.description}:${view.membership}:${view.outcome ?? ''}`) + /** The store's records for the session. */ + const records = () => describe(host.getStructuredChildWorkViews(parent)) + /** What the strip and the sidebar list. */ + const running = () => + describe(structuredRunningChildWork(host.getStructuredChildWorkViews(parent))) + const replay = (frames: readonly CapturedFrame[]) => { + for (const captured of frames) { + run.replay([captured]) + feed.publish(parent.sessionId) + } + } + /** The user writes a message now, on the host clock; the provider takes it once `accepted`. */ + const send = (clientMessageId: string): AgentJournalSubmission => { + const submission: AgentJournalSubmission = { + clientMessageId, + fence: 7, + payloadFingerprint: clientMessageId, + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: run.now(), + resolvedAt: null + } + submissions.push(submission) + feed.publish(parent.sessionId) + return submission + } + const accept = (submission: AgentJournalSubmission) => { + submission.dispatchState = 'accepted' + feed.publish(parent.sessionId) + } + const rootTurns = () => + [...run.journalItems.values()].filter((item) => !item.agentId && item.body.kind === 'turn') + return { run, feed, records, running, replay, send, accept, rootTurns } +} + +describe("a finished child's record stays until the user's next turn", () => { + it("keeps a background agent through Claude's own wake turn, then retires it at the next send", async () => { + const session = await wiredSession() + session.replay(MOVED_TO_BACKGROUND) + // The agent settled at +52,441 and Claude woke the agent at +54,303: a second root turn. + expect(session.rootTurns()).toHaveLength(2) + expect(session.records()).toContain('Run 45s sleep command:settled:succeeded') + // Nothing runs, so the strip and the sidebar list nothing. + expect(session.running()).toEqual([]) + + const next = session.send('next-send') + // Written, not yet taken: nothing retires on a send the provider may still refuse. + expect(session.records()).toContain('Run 45s sleep command:settled:succeeded') + session.accept(next) + expect(session.records()).toEqual([]) + }) + + it('keeps a background shell through the wake its completion opens, and a steer into that wake retires it', async () => { + const session = await wiredSession() + const { firstTurn, wake } = backgroundWakeCapture({ + sessionId: PROVIDER_SESSION_ID, + first: 'first-uuid', + steer: 'steer-uuid' + }) + session.accept(session.send('first-uuid')) + session.replay(firstTurn) + expect(session.running()).toEqual(['Sleep then print marker:live:']) + + const wakeInit = wake.findIndex((captured) => captured.frame.subtype === 'init') + session.replay(wake.slice(0, wakeInit + 1)) + // The completion frame opened Claude's own wake turn; the finished shell is still listed. + expect(session.rootTurns()).toHaveLength(2) + expect(session.records()).toEqual(['Sleep then print marker:settled:succeeded']) + expect(session.running()).toEqual([]) + + // The user steers while the wake runs; Claude folds it into that turn and takes it. + session.accept(session.send('steer-uuid')) + expect(session.records()).toEqual([]) + session.replay(wake.slice(wakeInit + 1)) + expect(session.records()).toEqual([]) + }) +}) diff --git a/src/main/claude/claude-child-work-decoder.test.ts b/src/main/claude/claude-child-work-decoder.test.ts index 9891f6c3f84d..0d11ba7a6a27 100644 --- a/src/main/claude/claude-child-work-decoder.test.ts +++ b/src/main/claude/claude-child-work-decoder.test.ts @@ -193,6 +193,16 @@ describe('Claude child-work decoder', () => { expect(decoder.drain(500)).toHaveLength(256) }) + it('ends what still runs as stopped when Orca ends the session, and only then', () => { + const decoder = decoderWith(backgroundAgent) + decoder.stopLive() + decoder.clear() + expect(decoder.drain(500)).toEqual([ + expect.objectContaining({ type: 'ended', outcome: 'cancelled' }), + { type: 'session-ended', observedAt: 500 } + ]) + }) + it('marks the end of the provider session', () => { const decoder = decoderWith(backgroundAgent) decoder.clear() diff --git a/src/main/claude/claude-child-work-decoder.ts b/src/main/claude/claude-child-work-decoder.ts index 6de305194b0a..e4cee7874696 100644 --- a/src/main/claude/claude-child-work-decoder.ts +++ b/src/main/claude/claude-child-work-decoder.ts @@ -3,13 +3,14 @@ // A child is live from its `task_started` until its own terminal `task_updated` or // `task_notification`; nothing else ends it. A roster (`background_tasks_changed`), a turn ending // or a spawn call returning is the parent's view of the child, not the child's, and the CLI sends -// every child its own terminal frame, so none of them settles one. When the session ends, the -// host settles whatever is still live. Edges wait here until the frame is journaled, then take -// the host clock. +// every child its own terminal frame, so none of them settles one. When Orca ends the session and +// proves its tree gone, what is still live is stopped (`stopLive`); any other end leaves it for the +// host to settle as unknown. Edges wait here until the frame is journaled, then take the host clock. import type { AgentChildWorkKind, - AgentChildWorkOperation + AgentChildWorkOperation, + AgentChildWorkOutcomeBasis } from '../../shared/agent-status-child-work' import type { AgentChildWorkEvidence, @@ -113,6 +114,24 @@ export class ClaudeChildWorkDecoder { } } + /** The CLI acknowledged a stop of this task, and one it no longer knows is acknowledged with no + * frame at all, so the acknowledgement ends a task still live here. Provisionally: the SDK hands + * the acknowledgement over as soon as it reads it, ahead of frames the CLI wrote before it, so + * the task's own ending may still arrive and replace this one. */ + stopAcknowledged(id: string): void { + if (this.live.has(id)) { + this.end(id, 'stopped', { basis: 'stop-acknowledged' }) + } + } + + /** Orca ended the session and proved its process tree gone: what still ran is stopped. The + * ending is Orca's, not the child's, so a frame of the child's own still replaces it. */ + stopLive(): void { + for (const id of this.live.keys()) { + this.end(id, 'stopped', { basis: 'stop-acknowledged' }) + } + } + /** The provider session is gone: the host settles what it still holds live. */ clear(): void { this.live.clear() @@ -229,7 +248,12 @@ export class ClaudeChildWorkDecoder { private end( id: string, status: unknown, - reported: { runId?: string; lastMessage?: string; totalTokens?: number } + reported: { + runId?: string + lastMessage?: string + totalTokens?: number + basis?: AgentChildWorkOutcomeBasis + } ): void { const task = this.live.get(id) this.live.delete(id) @@ -251,6 +275,7 @@ export class ClaudeChildWorkDecoder { ...(reported.runId !== undefined ? { runId: reported.runId } : {}) }, outcome, + ...(reported.basis ? { basis: reported.basis } : {}), ...(reported.lastMessage ? { lastMessage: reported.lastMessage } : {}), ...(reported.totalTokens !== undefined ? { totalTokens: reported.totalTokens } : {}) })) diff --git a/src/main/claude/claude-child-work-producer-harness.test-fixture.ts b/src/main/claude/claude-child-work-producer-harness.test-fixture.ts index 8b1a8b0ee917..0b460d91b4a7 100644 --- a/src/main/claude/claude-child-work-producer-harness.test-fixture.ts +++ b/src/main/claude/claude-child-work-producer-harness.test-fixture.ts @@ -2,6 +2,8 @@ // a store of its own or ingested by a real hook server. import { expect } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import { createAgentChildWorkAdmission } from '../../shared/agent-status-child-work-admission' import type { AgentChildWorkRecord } from '../../shared/agent-status-child-work' import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' @@ -70,7 +72,9 @@ export function toolResult( }) } -type Delivery = { kind: 'journal' | 'legacy' | 'evidence'; detail: string } +type Delivery = { kind: 'journal' | 'publish' | 'evidence'; detail: string } + +export type JournaledItem = Pick /** The host clock the adapter stamps evidence with; a replay moves it to each frame's time. */ export const T0 = 1_700_000_000_500 @@ -125,8 +129,6 @@ export async function producer(host?: AgentHookServer) { readProcessStartTime: async () => 1_700_000_000_000, now: () => clock, persistHandle: async () => {}, - onBackgroundTasksChanged: (_sessionId, state) => - deliveries.push({ kind: 'legacy', detail: String(state?.tasks?.length ?? 0) }), onChildWorkEvidence: (sessionId, evidence) => { expect(sessionId).toBe('session-1') deliveries.push({ kind: 'evidence', detail: evidence.map((edge) => edge.type).join(',') }) @@ -138,15 +140,25 @@ export async function producer(host?: AgentHookServer) { } } }) + /** What the adapter journaled, keyed by identity in first-write order, as the journal folds it. */ + const journalItems = new Map() const journal: StructuredAgentSessionEventSink = { - appendItem: (identity, _body, options) => { + appendItem: (identity, body, options) => { deliveries.push({ kind: 'journal', detail: JSON.stringify(identity) }) + const itemId = agentJournalItemKey(identity) + journalItems.set(itemId, { + itemId, + body, + sequence: journalItems.get(itemId)?.sequence ?? journalItems.size + 1, + ...(options?.agentId ? { agentId: options.agentId } : {}) + }) if (options?.agentId !== undefined) { stamps.push(options) } }, appendTombstone: () => {}, - publish: () => {} + // Production's journal publication is what republishes the parent's own row. + publish: () => deliveries.push({ kind: 'publish', detail: '' }) } await adapter.acquire({ identity: identityFor(), @@ -170,5 +182,19 @@ export async function producer(host?: AgentHookServer) { host ? host.getStructuredChildWork(parent) : store.getChildren(parent) const byDescription = (description: string) => records().find((record) => record.description === description) - return { adapter, store, send, replay, records, byDescription, evidenceLog, stamps, ingested } + return { + adapter, + claude, + /** The host clock the replay has reached. */ + now: () => clock, + store, + send, + replay, + records, + byDescription, + evidenceLog, + stamps, + ingested, + journalItems + } } diff --git a/src/main/claude/claude-close-stop-cause.test.ts b/src/main/claude/claude-close-stop-cause.test.ts new file mode 100644 index 000000000000..ba93c53a3d85 --- /dev/null +++ b/src/main/claude/claude-close-stop-cause.test.ts @@ -0,0 +1,92 @@ +// Closing a Claude child settles its open turn in the adapter, with the cause the host handed the +// close. Only a stop the user aimed at this chat reads as their cancellation; an exit the adapter +// saw before the close settles as that exit. + +import { describe, expect, it } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalTurnLifecycle +} from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import { + PROVIDER_SESSION_ID, + adapterFor, + fakeClaude, + identityFor, + tick +} from './claude-structured-session-test-support' + +function turnSink() { + const turns: AgentJournalTurnLifecycle[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (_identity, body: AgentJournalItemBody) => { + const turn = readAgentJournalTurn(body) + if (turn) { + turns.push(turn) + } + }, + appendTombstone: () => {}, + publish: () => {} + } + return { sink, turns } +} + +/** A live Claude child inside a turn the provider opened on its own (a background-task wake). */ +async function childInsideTurn() { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) + const { sink, turns } = turnSink() + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', events: sink }) + const connection = claude.connections[0]! + connection.handlers.onMessage?.({ + session_id: PROVIDER_SESSION_ID, + type: 'assistant', + uuid: 'assistant-1', + parent_tool_use_id: null, + message: { role: 'assistant', content: [{ type: 'text', text: 'working' }] } + }) + await tick() + expect(turns.at(-1)).toMatchObject({ state: 'running' }) + return { adapter, connection, events, turns } +} + +describe('a Claude close settles the open turn with the host-named cause', () => { + it("records the user's close of this chat as their cancellation", async () => { + const { adapter, events, turns } = await childInsideTurn() + + await expect(adapter.closeSession('session-1', 'user-close')).resolves.toBe(true) + + expect(turns.at(-1)).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(events.find((event) => event.type === 'ended')).toMatchObject({ + stopCause: 'user-close' + }) + }) + + it('leaves an eviction as news', async () => { + const { adapter, turns } = await childInsideTurn() + + await adapter.closeSession('session-1', 'evict') + + expect(turns.at(-1)).toMatchObject({ state: 'interrupted' }) + expect(turns.at(-1)).not.toHaveProperty('outcome') + }) + + it('leaves a crash it saw before the user closed the chat as news', async () => { + const { adapter, connection, events, turns } = await childInsideTurn() + // The child dies on its own first; the user's close arrives while that exit is still settling. + connection.handlers.onExit?.(new Error('provider exited')) + + await adapter.closeSession('session-1', 'user-close') + await tick() + + const settled = turns.filter((turn) => turn.state !== 'running') + expect(settled).toEqual([expect.objectContaining({ state: 'interrupted' })]) + expect(settled[0]).not.toHaveProperty('outcome') + expect(events.filter((event) => event.type === 'ended')).toEqual([ + expect.objectContaining({ cause: 'unexpected-exit' }) + ]) + }) +}) diff --git a/src/main/claude/claude-command-folder-scan.ts b/src/main/claude/claude-command-folder-scan.ts new file mode 100644 index 000000000000..574d73f7bba4 --- /dev/null +++ b/src/main/claude/claude-command-folder-scan.ts @@ -0,0 +1,88 @@ +import type { Dirent } from 'node:fs' +import { opendir, readFile } from 'node:fs/promises' +import { join, relative, sep } from 'node:path' +import type { AgentSessionSlashCommand } from '../../shared/agent-session-wire' +import { summarizeSkillMarkdown } from '../../shared/skill-metadata' + +const MARKDOWN_EXTENSION = '.md' +const MAX_SCAN_DEPTH = 24 +const MAX_SCAN_ENTRIES = 1_000 +const MAX_COMMAND_FILE_BYTES = 256 * 1024 + +type ScanBudget = { remainingEntries: number } + +/** A folder that cannot be read holds no commands we can offer, so it is skipped, not an error. */ +async function readEntries(path: string, budget: ScanBudget): Promise { + try { + const entries: Dirent[] = [] + for await (const entry of await opendir(path)) { + if (budget.remainingEntries === 0) { + break + } + budget.remainingEntries -= 1 + entries.push(entry) + } + return entries.sort((left, right) => left.name.localeCompare(right.name)) + } catch { + return [] + } +} + +async function walkMarkdown( + path: string, + depth: number, + budget: ScanBudget, + found: string[] +): Promise { + if (depth > MAX_SCAN_DEPTH || budget.remainingEntries === 0) { + return + } + for (const entry of await readEntries(path, budget)) { + // Links are not followed: a command folder is the user's own files, not a way out of it. + if (entry.isSymbolicLink()) { + continue + } + const entryPath = join(path, entry.name) + if (entry.isDirectory()) { + await walkMarkdown(entryPath, depth + 1, budget, found) + } else if (entry.isFile() && entry.name.endsWith(MARKDOWN_EXTENSION)) { + found.push(entryPath) + } + } +} + +async function describe(filePath: string): Promise { + try { + const markdown = await readFile(filePath, 'utf8') + return ( + summarizeSkillMarkdown(markdown.slice(0, MAX_COMMAND_FILE_BYTES)).description ?? undefined + ) + } catch { + return undefined + } +} + +/** + * Claude's custom commands in `roots`: every `*.md` below each folder, named by its path from + * the folder with `:` between segments (`frontend/test.md` is `/frontend:test`). The first root + * to name a command wins, so list the closest folder first. + */ +export async function scanClaudeCommandFolders( + roots: readonly string[] +): Promise { + const budget = { remainingEntries: MAX_SCAN_ENTRIES } + const commands = new Map() + for (const root of roots) { + const files: string[] = [] + await walkMarkdown(root, 0, budget, files) + for (const file of files) { + const name = relative(root, file).slice(0, -MARKDOWN_EXTENSION.length).split(sep).join(':') + if (commands.has(name)) { + continue + } + const description = await describe(file) + commands.set(name, { name, kind: 'command', ...(description ? { description } : {}) }) + } + } + return [...commands.values()] +} diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts index c5570e476fd1..f4182a601747 100644 --- a/src/main/claude/claude-command-lifecycle.test.ts +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -10,7 +10,13 @@ import { describe, expect, it } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { DISPATCH_REJECTED_CANCELLED } from '../../shared/structured-agent-session-dispatch-rejection' +import { + DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED, + DISPATCH_DOUBT_PROVIDER_IDLE +} from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' +import { agentSessionFailureFact } from '../../shared/agent-session-failure' import { ClaudeControlRequestError } from './claude-agent-sdk-control-requests' +import { claudeDispatchRejection } from './claude-structured-dispatch-content' import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' import type { ClaudeLateDispatchOutcome } from './claude-structured-session-state' import { @@ -96,6 +102,8 @@ async function replayCapture( omitFrame?: (frame: Record) => boolean /** Frames to deliver right after a captured one, in the capture's own uuids. */ afterFrame?: (frame: Record) => Record[] + /** Deliver this in place of a captured frame, as a CLI reporting another state would. */ + rewriteFrame?: (frame: Record) => Record /** Stop after the control request settles; its tail answers the CLI's own control path. */ stopAfterControl?: boolean } = {} @@ -161,7 +169,7 @@ async function replayCapture( if (options.omitFrame?.(frame)) { return } - const mapped = mapUuids(frame) + const mapped = mapUuids(options.rewriteFrame?.(frame) ?? frame) if (Array.isArray(mapped.capabilities) && options.withoutCapabilities) { mapped.capabilities = mapped.capabilities.filter( (capability) => !options.withoutCapabilities!.includes(String(capability)) @@ -366,6 +374,30 @@ describe('a queued send the CLI withdraws without Orca hearing why', () => { }) }) +/** The auth-failed capture's turn output: without it, its send started and got no echo. */ +function isTurnOutput(frame: Record): boolean { + return frame.type === 'user' || frame.type === 'assistant' || frame.type === 'result' +} + +function isLifecycleFrame(frame: Record, state?: string): boolean { + return frame.type === 'command_lifecycle' && (state === undefined || frame.state === state) +} + +function isIdleFrame(frame: Record): boolean { + return frame.subtype === 'session_state_changed' && frame.state === 'idle' +} + +/** The uuid a capture recorded for one of its sends, as its own frames name it. */ +function capturedUuid(name: string, clientMessageId: string): string { + const dispatch = loadCapture(name).find( + (event) => event.kind === 'dispatch' && event.clientMessageId === clientMessageId + ) + return dispatch?.kind === 'dispatch' ? dispatch.sentUuid : '' +} + +const ENDED_IN_DOUBT = { state: 'unknown', reason: DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED } +const IDLE_IN_DOUBT = { state: 'unknown', reason: DISPATCH_DOUBT_PROVIDER_IDLE } + describe('a send the CLI started, then cancelled', () => { it('stays accepted when its turn failed after the echo', async () => { const replay = await replayCapture('auth-failed') @@ -373,28 +405,220 @@ describe('a send the CLI started, then cancelled', () => { expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) }) - it('is not read as withdrawn when the cancelled frame came before any echo', async () => { - // The auth-failed capture with the turn's output removed: started, then cancelled. + it('is released as doubt, never withdrawn, when the cancelled frame came before any echo', async () => { + const replay = await replayCapture('auth-failed', { omitFrame: isTurnOutput }) + + expect(replay.settlementsFor('client-A')).toEqual([ENDED_IN_DOUBT]) + expect(replay.adapter.holdsDispatch('session-1')).toBe(false) + }) + + it('stays started when a redelivered command re-emits queued before its cancelled frame', async () => { const replay = await replayCapture('auth-failed', { + omitFrame: isTurnOutput, + afterFrame: (frame) => + isLifecycleFrame(frame, 'started') ? [{ ...frame, state: 'queued' }] : [] + }) + + expect(replay.settlementsFor('client-A')).toEqual([ENDED_IN_DOUBT]) + }) + + it('is still accepted when its echo lands after the doubt', async () => { + const replay = await replayCapture('auth-failed', { omitFrame: isTurnOutput }) + const uuid = replay.liveUuid('client-A') + + replay.connection.handlers.onMessage?.({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'late echo' }] }, + parent_tool_use_id: null, + session_id: PROVIDER_SESSION_ID, + uuid + }) + + expect(replay.settlementsFor('client-A')).toEqual([ENDED_IN_DOUBT, acceptedAs(uuid)]) + }) + + it.each(['discarded', 'refused'])( + 'is released as doubt when its turn ends %s after it started', + async (state) => { + const replay = await replayCapture('auth-failed', { + omitFrame: isTurnOutput, + rewriteFrame: (frame) => + isLifecycleFrame(frame, 'cancelled') ? { ...frame, state } : frame + }) + + expect(replay.settlementsFor('client-A')).toEqual([ENDED_IN_DOUBT]) + } + ) +}) + +// batch-lead's client-C is a steer: written while A's turn runs, queued, then started as its own +// turn once A completed. Its queued-while-Stopped case is interrupt-lost's client-B, above. +describe('a steer a Stop interrupts after it started', () => { + it('is released as doubt, never withdrawn, when the Stop lands before its echo', async () => { + const steer = capturedUuid('batch-lead', 'client-C') + let started = false + let routes: ControlPoint['routes'] | undefined + const replay = await replayCapture('batch-lead', { + atControl: async (point) => { + routes = point.routes + point.deliverInFlight() + }, + // The Stop lands between the steer's start and its echo: nothing after its start arrives. + omitFrame: () => started, + afterFrame: (frame) => { + started ||= isLifecycleFrame(frame, 'started') && frame.command_uuid === steer + return [] + } + }) + const uuid = replay.liveUuid('client-C') + expect(replay.adapter.holdsDispatch('session-1')).toBe(true) + + // Answered as interrupt-lost's was, with nothing left queued; then that capture's tail for a + // started command a Stop ended (its result dropped: one naming the steer would accept it). + routes!.interrupt = () => ({ still_queued: [], cancelled: [] }) + await replay.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) + for (const frame of [ + { + type: 'command_lifecycle', + command_uuid: uuid, + state: 'cancelled', + uuid: 'steer-cancelled' + }, + { type: 'system', subtype: 'session_state_changed', state: 'idle', uuid: 'steer-idle' } + ]) { + replay.connection.handlers.onMessage?.({ ...frame, session_id: PROVIDER_SESSION_ID }) + } + + expect(replay.connection.calls.filter((call) => call.subtype === 'interrupt')).toEqual([ + { subtype: 'interrupt', params: { cancelQueued: true } } + ]) + expect(replay.settlementsFor('client-C')).toEqual([ENDED_IN_DOUBT]) + expect(replay.adapter.holdsDispatch('session-1')).toBe(false) + }) +}) + +describe('a send the CLI ended before starting it', () => { + it.each([ + // The CLI ended its session with the send still queued. + ['discarded', 'notDelivered', false], + // Declined before it queued, so no `queued` frame precedes it. + ['refused', 'providerRejected', true] + ] as const)('settles a %s send as not sent, saying why', async (state, kind, neverQueued) => { + const followUp = capturedUuid('cancel-async', 'client-B') + const replay = await replayCapture('cancel-async', { omitFrame: (frame) => - frame.type === 'user' || frame.type === 'assistant' || frame.type === 'result' + neverQueued && isLifecycleFrame(frame, 'queued') && frame.command_uuid === followUp, + rewriteFrame: (frame) => + isLifecycleFrame(frame, 'cancelled') && frame.command_uuid === followUp + ? { ...frame, state } + : frame }) - expect(replay.settlementsFor('client-A')).toEqual([]) + expect(replay.settlementsFor('client-B')).toEqual([ + { state: 'rejected', ...claudeDispatchRejection(agentSessionFailureFact(kind)) } + ]) + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) }) +}) - it('stays started when a redelivered command re-emits queued before its cancelled frame', async () => { +describe('a send the CLI holds', () => { + it('holds its child from the idle sweep until the CLI idles, then is released as doubt', async () => { + // Started, then its turn threw: no echo and no terminal state, as the schema allows. const replay = await replayCapture('auth-failed', { omitFrame: (frame) => - frame.type === 'user' || frame.type === 'assistant' || frame.type === 'result', + isTurnOutput(frame) || isLifecycleFrame(frame, 'cancelled') || isIdleFrame(frame) + }) + expect(replay.adapter.holdsDispatch('session-1')).toBe(true) + expect(replay.settlementsFor('client-A')).toEqual([]) + + replay.connection.handlers.onMessage?.({ + type: 'system', + subtype: 'session_state_changed', + state: 'idle', + uuid: 'idle-after-throw', + session_id: PROVIDER_SESSION_ID + }) + + expect(replay.settlementsFor('client-A')).toEqual([IDLE_IN_DOUBT]) + expect(replay.adapter.holdsDispatch('session-1')).toBe(false) + }) + + // 2.1.280's end-of-turn cleanup can report idle before it re-reads its queue. + it('leaves a queued send the CLI idles before starting to open its turn and be accepted', async () => { + const replay = await replayCapture('auth-failed', { afterFrame: (frame) => - frame.type === 'command_lifecycle' && frame.state === 'started' - ? [{ ...frame, state: 'queued' }] + isLifecycleFrame(frame, 'queued') + ? [ + { + type: 'system', + subtype: 'session_state_changed', + state: 'idle', + uuid: 'idle-before-start', + session_id: frame.session_id + } + ] : [] }) + const uuid = replay.liveUuid('client-A') + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(uuid)]) + expect(replay.turnStates.get(uuid)).toBe('completed') + }) + + it('holds the child for a send the CLI has only queued, and does not release it at idle', async () => { + const replay = await replayCapture('auth-failed', { + omitFrame: (frame) => + isTurnOutput(frame) || + isLifecycleFrame(frame, 'started') || + isLifecycleFrame(frame, 'cancelled') + }) + + expect(replay.idles).toEqual(['session-1']) + expect(replay.adapter.holdsDispatch('session-1')).toBe(true) expect(replay.settlementsFor('client-A')).toEqual([]) }) + + it('holds a queued send until the CLI withdraws it', async () => { + const held: boolean[] = [] + const replay = await replayCapture('cancel-async', { + atControl: async (point) => { + // The lead was echoed already; only the queued follow-up is left. + held.push(point.adapter.holdsDispatch('session-1')) + point.deliverInFlight() + held.push(point.adapter.holdsDispatch('session-1')) + } + }) + + expect(held).toEqual([true, false]) + expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN]) + }) + + it('holds a queued send until it starts and is echoed', async () => { + const held: boolean[] = [] + const replay = await replayCapture('batch-lead', { + // Without its turn's end or the idle, only the start and echo can let it go. + omitFrame: (frame) => + frame.type === 'result' || isLifecycleFrame(frame, 'completed') || isIdleFrame(frame), + atControl: async (point) => { + point.deliverInFlight() + // The batch lead is withdrawn; the send behind it is only queued. + held.push(point.adapter.holdsDispatch('session-1')) + } + }) + + expect(held).toEqual([true]) + expect(replay.adapter.holdsDispatch('session-1')).toBe(false) + expect(replay.settlementsFor('client-C')).toEqual([acceptedAs(replay.liveUuid('client-C'))]) + }) + + it.each(['interrupt-lost', 'cancel-async', 'batch-lead', 'auth-failed'])( + 'holds nothing once the %s capture has run to its idle', + async (name) => { + const replay = await replayCapture(name) + + expect(replay.adapter.holdsDispatch('session-1')).toBe(false) + } + ) }) describe('the CLI reporting its session idle', () => { diff --git a/src/main/claude/claude-command-lifecycle.ts b/src/main/claude/claude-command-lifecycle.ts index 652a431d5383..a5d4408799bd 100644 --- a/src/main/claude/claude-command-lifecycle.ts +++ b/src/main/claude/claude-command-lifecycle.ts @@ -1,15 +1,37 @@ // What Claude's per-command `command_lifecycle` frames (msg_lifecycle_v1) settle. // -// `cancelled` is not by itself a withdrawal: a command the CLI already started also ends -// `cancelled` when its turn is interrupted or fails (measured on 2.1.280). Only a command -// cancelled before it started was withdrawn. That frame lands ahead of the control answer, so -// it settles the send even when the interrupt or cancel_async_message answer is lost. +// A terminal state for a command that never started is a verdict: `cancelled` was withdrawn, +// `discarded` was dropped when the CLI ended its session, `refused` was declined before it +// queued. None ran. After `started`, 2.1.280 sends only `cancelled`, when an interrupt or a hard +// failure ends the turn (measured), so the send may be in the conversation: any end is doubt. +// An unstarted send's `cancelled` lands before the Stop's answer, so it settles if that is lost. -import { settleCancelledClaudeDispatchWaiters } from './claude-structured-dispatch' +import { + agentSessionFailureFact, + type SubmissionRejectionKind +} from '../../shared/agent-session-failure' +import { + DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED, + DISPATCH_DOUBT_PROVIDER_IDLE +} from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' +import { + rejectClaudeDispatchWaiters, + releaseClaudeDispatchWaitersInDoubt +} from './claude-structured-dispatch' import { readClaudeFrameString } from './claude-structured-init-proof' import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution' import type { ClaudeSession } from './claude-structured-session-state' +const UNSTARTED_VERDICT = { + cancelled: 'cancelled', + discarded: 'notDelivered', + refused: 'providerRejected' +} satisfies Record + +function isTerminalState(state: unknown): state is keyof typeof UNSTARTED_VERDICT { + return state === 'cancelled' || state === 'discarded' || state === 'refused' +} + export function observeClaudeCommandLifecycle( session: ClaudeSession, message: Record, @@ -27,7 +49,36 @@ export function observeClaudeCommandLifecycle( if (state === 'started' || (state === 'queued' && waiter.commandLifecycle !== 'started')) { // Forward only: a redelivered command re-emits `queued`, but it has still started. waiter.commandLifecycle = state - } else if (state === 'cancelled' && waiter.commandLifecycle !== 'started') { - settleCancelledClaudeDispatchWaiters(session, [waiter.sentUuid], onSettledLate) + } else if (isTerminalState(state) && waiter.commandLifecycle === 'started') { + releaseClaudeDispatchWaitersInDoubt( + session, + [waiter], + DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED, + onSettledLate + ) + } else if (isTerminalState(state)) { + rejectClaudeDispatchWaiters( + session, + [waiter.sentUuid], + agentSessionFailureFact(UNSTARTED_VERDICT[state]), + onSettledLate + ) } } + +/** A send the CLI took, until its echo, a terminal state or exit: the sweep must not rest its child. */ +export function claudeHoldsDispatch(session: ClaudeSession): boolean { + return session.dispatchWaiters.some((waiter) => waiter.commandLifecycle !== undefined) +} + +/** + * At idle, only a `started` send still unanswered is doubt: a turn that threw leaves it with no + * terminal state. A `queued` one may still start: 2.1.280's code idles before re-reading its queue. + */ +export function releaseClaudeDispatchesUnansweredAtIdle( + session: ClaudeSession, + onSettledLate?: ClaudeLateDispatchSettlement +): void { + const started = session.dispatchWaiters.filter((waiter) => waiter.commandLifecycle === 'started') + releaseClaudeDispatchWaitersInDoubt(session, started, DISPATCH_DOUBT_PROVIDER_IDLE, onSettledLate) +} diff --git a/src/main/claude/claude-context-usage.test.ts b/src/main/claude/claude-context-usage.test.ts index af86a7fddee0..08677a1fb930 100644 --- a/src/main/claude/claude-context-usage.test.ts +++ b/src/main/claude/claude-context-usage.test.ts @@ -157,21 +157,6 @@ describe('claudeContextWindowFromResult', () => { ).toBe(200_000) }) - it('keeps the main thread window when a subagent ran on a larger one', () => { - const modelUsage = { - 'claude-sonnet-5': { contextWindow: 200_000 }, - 'claude-fable-5-1[1m]': { contextWindow: 1_000_000 } - } - for (const initModel of ['claude-sonnet-5', null]) { - expect( - claudeContextWindowFromResult( - { modelUsage }, - { initModel, responseModel: 'claude-sonnet-5' } - ) - ).toBe(200_000) - } - }) - it('is null when no entry reports a usable window', () => { expect(claudeContextWindowFromResult({ type: 'result' })).toBeNull() expect( diff --git a/src/main/claude/claude-folder-trust-file-wsl-guest.test.ts b/src/main/claude/claude-folder-trust-file-wsl-guest.test.ts new file mode 100644 index 000000000000..e831b7c08eed --- /dev/null +++ b/src/main/claude/claude-folder-trust-file-wsl-guest.test.ts @@ -0,0 +1,133 @@ +import type * as NodeFs from 'node:fs' +import { + chmodSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WslResult, WslSpec } from '../wsl/wsl-runner' +import { grantClaudeFolderTrust } from './claude-folder-trust-file' + +const { DISTRO, GUEST_PREFIX, guest } = vi.hoisted(() => ({ + DISTRO: 'Ubuntu-24.04', + GUEST_PREFIX: '//wsl.localhost/Ubuntu-24.04', + guest: { root: '' } +})) + +function toGuestDisk(path: string): string { + return path.startsWith(GUEST_PREFIX) ? `${guest.root}${path.slice(GUEST_PREFIX.length)}` : path +} + +// Why: models Windows Node over \\wsl.localhost: a synthetic 0o666 mode, chmod that cannot reach +// the guest bits, and new files at the guest's default 0644 whatever mode was requested. +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + const onGuest = (path: unknown): path is string => + typeof path === 'string' && path.startsWith(GUEST_PREFIX) + const disk = (path: string): string => `${guest.root}${path.slice(GUEST_PREFIX.length)}` + const windowsView = (stats: NodeFs.Stats): NodeFs.Stats => + Object.assign(stats, { mode: (stats.mode & ~0o777) | 0o666 }) + return { + ...actual, + existsSync: (path: string) => actual.existsSync(onGuest(path) ? disk(path) : path), + lstatSync: (path: string) => + onGuest(path) ? windowsView(actual.lstatSync(disk(path))) : actual.lstatSync(path), + statSync: (path: string) => + onGuest(path) ? windowsView(actual.statSync(disk(path))) : actual.statSync(path), + realpathSync: (path: string) => + onGuest(path) + ? `${GUEST_PREFIX}${actual.realpathSync(disk(path)).slice(guest.root.length)}` + : actual.realpathSync(path), + readFileSync: (path: string, options: BufferEncoding) => + actual.readFileSync(onGuest(path) ? disk(path) : path, options), + writeFileSync: (path: string, data: string, options?: NodeFs.WriteFileOptions) => { + if (!onGuest(path)) { + return actual.writeFileSync(path, data, options) + } + const created = !actual.existsSync(disk(path)) + const flag = typeof options === 'object' && options ? options.flag : undefined + actual.writeFileSync(disk(path), data, { flag }) + if (created) { + actual.chmodSync(disk(path), 0o644) + } + }, + chmodSync: (path: string, mode: number) => + onGuest(path) ? undefined : actual.chmodSync(path, mode), + renameSync: (from: string, to: string) => + actual.renameSync(onGuest(from) ? disk(from) : from, onGuest(to) ? disk(to) : to), + rmSync: (path: string, options?: NodeFs.RmOptions) => + actual.rmSync(onGuest(path) ? disk(path) : path, options) + } +}) + +vi.mock('proper-lockfile', () => ({ + lock: vi.fn(async () => async () => {}) +})) + +const runWslProcess = vi.hoisted(() => vi.fn<(spec: WslSpec) => Promise>()) +vi.mock('../wsl/wsl-runner', () => ({ runWslProcess })) + +/** Runs `chmod --reference= -- ` as the guest would. */ +async function guestChmod(spec: WslSpec): Promise { + const [reference, separator, target] = spec.args ?? [] + expect(spec).toMatchObject({ distro: DISTRO, loginPath: 'none', program: 'chmod' }) + expect(separator).toBe('--') + const from = `${guest.root}${reference.replace(/^--reference=/, '')}` + chmodSync(`${guest.root}${target}`, statSync(from).mode & 0o7777) + return { environmentResolved: true, code: 0, stdout: '', stderr: '', timedOut: false } +} + +const originalPlatform = process.platform + +describe.skipIf(originalPlatform === 'win32')('grantClaudeFolderTrust on a WSL guest file', () => { + const guestFile = `${GUEST_PREFIX}/home/u/.claude.json` + + beforeEach(() => { + guest.root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-claude-trust-guest-'))) + mkdirSync(join(guest.root, 'home/u'), { recursive: true }) + writeFileSync(toGuestDisk(guestFile), JSON.stringify({ theme: 'dark' })) + chmodSync(toGuestDisk(guestFile), 0o600) + runWslProcess.mockReset().mockImplementation(guestChmod) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + rmSync(guest.root, { recursive: true, force: true }) + }) + + it("keeps the guest file's owner-only mode through the rewrite", async () => { + await expect( + grantClaudeFolderTrust({ configFile: guestFile, folderKeys: ['/home/u/wt'] }) + ).resolves.toBe('granted') + expect(statSync(toGuestDisk(guestFile)).mode & 0o777).toBe(0o600) + expect(JSON.parse(readFileSync(toGuestDisk(guestFile), 'utf-8'))).toEqual({ + theme: 'dark', + projects: { '/home/u/wt': { hasTrustDialogAccepted: true } } + }) + }) + + it('leaves the file untouched when the guest cannot copy its mode', async () => { + runWslProcess.mockResolvedValue({ + environmentResolved: true, + code: 1, + stdout: '', + stderr: "chmod: unrecognized option '--reference'", + timedOut: false + }) + await expect( + grantClaudeFolderTrust({ configFile: guestFile, folderKeys: ['/home/u/wt'] }) + ).rejects.toThrow(/guest mode/) + expect(readFileSync(toGuestDisk(guestFile), 'utf-8')).toBe(JSON.stringify({ theme: 'dark' })) + expect(statSync(toGuestDisk(guestFile)).mode & 0o777).toBe(0o600) + expect(readdirSync(join(guest.root, 'home/u'))).toEqual(['.claude.json']) + }) +}) diff --git a/src/main/claude/claude-folder-trust-file.test.ts b/src/main/claude/claude-folder-trust-file.test.ts index 1c876be14574..456e8938cb6f 100644 --- a/src/main/claude/claude-folder-trust-file.test.ts +++ b/src/main/claude/claude-folder-trust-file.test.ts @@ -4,6 +4,7 @@ import { lstatSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, realpathSync, rmSync, @@ -178,6 +179,8 @@ describe('grantClaudeFolderTrust', () => { ) expect(readConfig(file)).toEqual({}) expect(existsSync(lockDir)).toBe(true) + // Why: the replacement file is created before the lock, so a skipped write must remove it. + expect(readdirSync(root).sort()).toEqual(['.claude.json', '.claude.json.lock']) }) it('updates a symlinked config through its target and keeps the link', async () => { diff --git a/src/main/claude/claude-folder-trust-file.ts b/src/main/claude/claude-folder-trust-file.ts index ca35351eeb0c..5ec9bccebccb 100644 --- a/src/main/claude/claude-folder-trust-file.ts +++ b/src/main/claude/claude-folder-trust-file.ts @@ -15,6 +15,7 @@ import { lock } from 'proper-lockfile' import { renameFileWithWindowsRetry } from '../codex-accounts/fs-utils' import { runKeyedSerializedOperation } from '../cli/keyed-promise-queue' import { parseWslUncPath } from '../../shared/wsl-paths' +import { runWslProcess } from '../wsl/wsl-runner' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth/runtime-auth-types' export type ClaudeTrustPathStyle = 'posix' | 'win32' @@ -144,20 +145,52 @@ function readConfigObject(target: string): Record | null { } } -function writeConfigAtomically(target: string, config: Record): void { - const mode = statSync(target).mode & 0o777 - const tmpPath = `${target}.orca-trust-${randomUUID()}.tmp` +type ReplacementFile = { target: string; path: string } + +/** + * Creates an empty file beside `target` that already has `target`'s permission bits, so + * renaming it over `target` can never widen them. + */ +async function createReplacementFile(target: string): Promise { + const suffix = `.orca-trust-${randomUUID()}.tmp` + const path = `${target}${suffix}` + const guestFile = parseWslUncPath(target) try { - writeFileSync(tmpPath, `${JSON.stringify(config, null, 2)}\n`, { encoding: 'utf-8', mode }) - if (process.platform !== 'win32') { - // Why: umask may narrow the requested mode; the replacement must match the original exactly. - chmodSync(tmpPath, mode) + if (guestFile) { + // Why: Windows sees a synthetic 0o666 for a guest file and cannot set its bits, and the + // guest gives a file made through \\wsl.localhost its default 0644; only the guest can copy them. + writeFileSync(path, '', { flag: 'wx' }) + const copied = await runWslProcess({ + distro: guestFile.distro, + loginPath: 'none', + program: 'chmod', + args: [`--reference=${guestFile.linuxPath}`, '--', `${guestFile.linuxPath}${suffix}`] + }) + if (copied.code !== 0) { + throw new Error(`could not copy the guest mode of ${target}: ${copied.stderr.trim()}`) + } + } else { + const mode = statSync(target).mode & 0o777 + writeFileSync(path, '', { flag: 'wx', mode }) + if (process.platform !== 'win32') { + // Why: umask may narrow the requested mode; the replacement must match the original exactly. + chmodSync(path, mode) + } } - renameFileWithWindowsRetry(tmpPath, target) } catch (error) { - rmSync(tmpPath, { force: true }) + rmSync(path, { force: true }) throw error } + return { target, path } +} + +function replaceConfig(replacement: ReplacementFile, config: Record): void { + // Why r+: reopening without create/truncate keeps the mode the replacement was given. + writeFileSync(replacement.path, `${JSON.stringify(config, null, 2)}\n`, { + encoding: 'utf-8', + flag: 'r+' + }) + renameFileWithWindowsRetry(replacement.path, replacement.target) } /** @@ -188,37 +221,49 @@ async function grantClaudeFolderTrustNow(args: { return planned === 'refuse' ? 'unreadable' : 'unchanged' } - let release: () => Promise + // Why before the lock: a WSL guest's mode takes a guest process to copy, and Claude's + // lock should stay held only for the synchronous read → rename below. + const replacement = await createReplacementFile(probe.path) try { - release = await lock(args.configFile, { - // Why: Claude locks the literal `.lock`, not a realpath'd one. - lockfilePath: `${args.configFile}.lock`, - realpath: false, - stale: NEVER_STALE_MS, - retries: LOCK_RETRIES, - onCompromised: () => {} - }) - } catch { - return 'locked' - } - try { - // Why: read → rename stays synchronous so Orca's own synchronous auth writer to - // this file cannot interleave and lose an update. - const current = readConfigAt(resolveConfigTarget(args.configFile)) - if (typeof current === 'string') { - return current - } - const change = applyClaudeFolderTrust(current.config, args.folderKeys) - if (change.kind === 'refuse') { - return 'unreadable' + let release: () => Promise + try { + release = await lock(args.configFile, { + // Why: Claude locks the literal `.lock`, not a realpath'd one. + lockfilePath: `${args.configFile}.lock`, + realpath: false, + stale: NEVER_STALE_MS, + retries: LOCK_RETRIES, + onCompromised: () => {} + }) + } catch { + return 'locked' } - if (change.kind === 'unchanged') { - return 'unchanged' + try { + // Why: read → rename stays synchronous so Orca's own synchronous auth writer to + // this file cannot interleave and lose an update. + const current = readConfigAt(resolveConfigTarget(args.configFile)) + if (typeof current === 'string') { + return current + } + // Why: a link retargeted since the replacement copied its mode means Claude asks. + if (current.path !== replacement.target) { + return 'unreadable' + } + const change = applyClaudeFolderTrust(current.config, args.folderKeys) + if (change.kind === 'refuse') { + return 'unreadable' + } + if (change.kind === 'unchanged') { + return 'unchanged' + } + replaceConfig(replacement, change.config) + return 'granted' + } finally { + await release().catch(() => {}) } - writeConfigAtomically(current.path, change.config) - return 'granted' } finally { - await release().catch(() => {}) + // Why: a no-op after the rename; otherwise the unused replacement must not linger. + rmSync(replacement.path, { force: true }) } } diff --git a/src/main/claude/claude-journal-translator-contract.ts b/src/main/claude/claude-journal-translator-contract.ts index 45feeb84364a..24884205ac2f 100644 --- a/src/main/claude/claude-journal-translator-contract.ts +++ b/src/main/claude/claude-journal-translator-contract.ts @@ -2,6 +2,7 @@ import type { AgentSessionContextReport } from '../../shared/agent-session-context-usage' import type { StructuredAgentSessionSinkAdmission } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeChildToolQueries } from './claude-child-tool-queries' import type { ClaudeContextReportPart, ClaudeContextReportTarget } from './claude-context-facts' import type { ClaudeJournalPrompts } from './claude-structured-journal-prompts' @@ -10,10 +11,13 @@ import type { ClaudeCommandStart } from './claude-command-turn' export type ClaudeJournalTranslator = { handle: (event: ClaudeStructuredSessionEvent) => void - journalPrompts: Pick + journalPrompts: Pick /** The open turn's provider id — the same id its journal row carries, and the one * a client's Stop names. Sole owner: no reader keeps a copy to disagree with. */ readonly currentTurnId: string | null + /** Orca is stopping this turn; its error end reads as the user's cancellation when they asked. + * False when the turn is no longer open. */ + recordTurnStop: (turnId: string, cause: StructuredAgentSessionStopCause) => boolean /** The open turn's id while it is a conversation command's. */ readonly commandTurnId: string | null /** Makes the host's command turn the open one until the command's result ends it. */ diff --git a/src/main/claude/claude-open-turn.ts b/src/main/claude/claude-open-turn.ts index 864624fe59e0..dc5a9c8dcf71 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -12,6 +12,7 @@ import { type AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { claudeCurrentTurnIdentity, claudeTurnLifecycleItem, @@ -32,6 +33,9 @@ export type ClaudeOpenTurnDeps = { export class ClaudeOpenTurn { private current: ClaudeCurrentTurn | null = null + /** The stop Orca sent, held against the turn it was sent to: it reads only while that turn is open. */ + private sentStop: { turn: ClaudeCurrentTurn; cause: StructuredAgentSessionStopCause } | null = + null /** Provider output may not reopen a turn after the session ended or a turn * failed: nothing would ever close the turn it opened, and the row would read * working for the life of the session. Only an accepted send lifts it. */ @@ -86,6 +90,19 @@ export class ClaudeOpenTurn { return this.current !== null } + get stop(): StructuredAgentSessionStopCause | null { + return this.current && this.sentStop?.turn === this.current ? this.sentStop.cause : null + } + + /** Orca is stopping `turnId`. False when that turn is no longer the open one. */ + recordStop(turnId: string, cause: StructuredAgentSessionStopCause): boolean { + if (this.current?.turnId !== turnId) { + return false + } + this.sentStop = { turn: this.current, cause } + return true + } + /** Whether a turn is open inside a provider request cycle that has already * done work — the state in which the CLI folds an arriving send into it. A * cycle's first send is its opener, never a fold. */ @@ -105,12 +122,17 @@ export class ClaudeOpenTurn { /** Open a turn, ending whichever one was still open. A new turn starting is the * only end the previous one gets when its result never arrives; settling it - * later would sweep THIS turn. */ + * later would sweep THIS turn. The replaced turn is recorded superseded: a newer + * request ended it, whoever sent that request. */ open(turn: ClaudeCurrentTurn, observedAt: number): void { this.deps.onOpen?.() if (this.current) { this.deps.settleChildren(this.groupKey) - this.publish(this.current, { state: 'interrupted', completedAt: observedAt }) + this.publish(this.current, { + state: 'interrupted', + completedAt: observedAt, + outcome: 'superseded' + }) } this.current = turn this.publish(turn) @@ -123,7 +145,11 @@ export class ClaudeOpenTurn { this.deps.onOpen?.() if (this.current) { this.deps.settleChildren(this.groupKey) - this.publish(this.current, { state: 'interrupted', completedAt: turn.startedAt }) + this.publish(this.current, { + state: 'interrupted', + completedAt: turn.startedAt, + outcome: 'superseded' + }) } this.current = turn this.deps.sink.setActivity?.(null) diff --git a/src/main/claude/claude-prompt-registry.ts b/src/main/claude/claude-prompt-registry.ts index 5cd2f6b1a7b9..bb8febab21b1 100644 --- a/src/main/claude/claude-prompt-registry.ts +++ b/src/main/claude/claude-prompt-registry.ts @@ -27,7 +27,6 @@ export type ClaudePendingPrompt = ClaudePromptPresentation & { suggestions: PermissionUpdate[] questionIds: readonly string[] settle: ClaudePromptSettle - turnId?: string | null } export type ClaudePromptRegistration = ClaudePromptPresentation & { @@ -37,12 +36,6 @@ export type ClaudePromptRegistration = ClaudePromptPresentation & { input: Record suggestions: PermissionUpdate[] settle: ClaudePromptSettle - turnId?: string | null -} - -type PromptBinding = { - address: string - turnId: string | null } export type ClaudePromptClaim = { @@ -50,11 +43,6 @@ export type ClaudePromptClaim = { readonly found: { prompt: ClaudePendingPrompt } } -type ClaudePromptCancellationObservation = { - promise: Promise - resolve: () => void -} - export function isClaudePromptRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } @@ -78,12 +66,9 @@ function questionId(question: Record, index: number): string { /** Session-local callback ownership; none of this state is reconstructed from the transcript. */ export class ClaudePromptRegistry { private readonly prompts = new Map() - private readonly journalBindings = new Map() + /** Journal item id to the prompt key it shows. */ + private readonly journalBindings = new Map() private readonly claims = new Map() - private readonly cancellationObservations = new WeakMap< - ClaudePendingPrompt, - ClaudePromptCancellationObservation - >() register(registration: ClaudePromptRegistration): ClaudePendingPrompt | null { const toolUseId = readClaudePromptString(registration.toolUseId) @@ -109,8 +94,7 @@ export class ClaudePromptRegistry { ...(registration.matchedAskRule ? { matchedAskRule: registration.matchedAskRule } : {}), ...(registration.subject ? { subject: registration.subject } : {}), questionIds: questions.map(questionId), - settle: registration.settle, - turnId: registration.turnId ?? null + settle: registration.settle } this.prompts.set(prompt.promptKey, prompt) return prompt @@ -121,23 +105,17 @@ export class ClaudePromptRegistry { if (!this.prompts.has(prompt.promptKey)) { return false } - const observation = this.cancellationObservations.get(prompt) this.forget(prompt) - observation?.resolve() return true } - bindJournalItemId(journalItemId: string, promptKey: string, turnId: string | null = null): void { - const prompt = this.prompts.get(promptKey) - this.journalBindings.set(journalItemId, { - address: promptKey, - turnId: turnId ?? prompt?.turnId ?? null - }) + bindJournalItemId(journalItemId: string, promptKey: string): void { + this.journalBindings.set(journalItemId, promptKey) } find(itemId: string): { prompt: ClaudePendingPrompt } | null { - const binding = this.journalBindings.get(itemId) - const prompt = this.prompts.get(binding?.address ?? itemId) + const promptKey = this.journalBindings.get(itemId) + const prompt = this.prompts.get(promptKey ?? itemId) return prompt ? { prompt } : null } @@ -151,17 +129,6 @@ export class ClaudePromptRegistry { return claim } - claimBound(itemId: string, turnId: string): ClaudePromptClaim | null { - const binding = this.journalBindings.get(itemId) - const prompt = binding ? this.prompts.get(binding.address) : undefined - if (!binding || !prompt || binding.turnId !== turnId || this.claims.has(prompt)) { - return null - } - const claim = { itemId, found: { prompt } } - this.claims.set(prompt, claim) - return claim - } - ownsClaim(claim: ClaudePromptClaim): boolean { return ( this.claims.get(claim.found.prompt) === claim && @@ -169,39 +136,12 @@ export class ClaudePromptRegistry { ) } - ownsBoundClaim(claim: ClaudePromptClaim, itemId: string, turnId: string): boolean { - const binding = this.journalBindings.get(itemId) - return ( - claim.itemId === itemId && - this.claims.get(claim.found.prompt) === claim && - binding?.address === claim.found.prompt.promptKey && - binding.turnId === turnId && - this.prompts.get(binding.address) === claim.found.prompt - ) - } - releaseClaim(claim: ClaudePromptClaim): void { if (this.claims.get(claim.found.prompt) === claim) { this.claims.delete(claim.found.prompt) } } - observeCancellation(claim: ClaudePromptClaim): Promise | null { - if (!this.ownsClaim(claim)) { - return null - } - let observation = this.cancellationObservations.get(claim.found.prompt) - if (!observation) { - let resolve = (): void => {} - const promise = new Promise((settled) => { - resolve = settled - }) - observation = { promise, resolve } - this.cancellationObservations.set(claim.found.prompt, observation) - } - return observation.promise - } - cancel(requestId: string): ClaudePendingPrompt | null { const prompt = this.prompts.get(requestId) ?? null if (prompt) { @@ -213,8 +153,8 @@ export class ClaudePromptRegistry { forget(prompt: ClaudePendingPrompt): void { this.claims.delete(prompt) this.prompts.delete(prompt.promptKey) - for (const [itemId, binding] of this.journalBindings) { - if (binding.address === prompt.promptKey) { + for (const [itemId, promptKey] of this.journalBindings) { + if (promptKey === prompt.promptKey) { this.journalBindings.delete(itemId) } } @@ -225,9 +165,6 @@ export class ClaudePromptRegistry { this.prompts.clear() this.journalBindings.clear() this.claims.clear() - for (const prompt of pending) { - this.cancellationObservations.get(prompt)?.resolve() - } return pending } } diff --git a/src/main/claude/claude-replay-turn-resolution.ts b/src/main/claude/claude-replay-turn-resolution.ts index 9db40feac49e..fe2bb1523f95 100644 --- a/src/main/claude/claude-replay-turn-resolution.ts +++ b/src/main/claude/claude-replay-turn-resolution.ts @@ -3,10 +3,14 @@ // A replay or result is joined to its waiter by the client uuid Claude echoes. // A send Claude FOLDS into the running request cycle is replayed mid-cycle with // the client uuid adopted: once that cycle has done work, that replay is a -// delivery receipt and opens no boundary. A `command_lifecycle` frame opens no turn; its -// `cancelled` can settle a send the CLI withdrew (`claude-command-lifecycle.ts`). +// delivery receipt and opens no boundary. A `command_lifecycle` frame opens no turn; it and the +// CLI going idle settle sends the CLI took but never echoed (`claude-command-lifecycle.ts`). -import { observeClaudeCommandLifecycle } from './claude-command-lifecycle' +import { + observeClaudeCommandLifecycle, + releaseClaudeDispatchesUnansweredAtIdle +} from './claude-command-lifecycle' +import { claudeSessionStateEndsTurn } from './claude-session-state-turn-over' import { forgetRetiredWaiter } from './claude-structured-dispatch-waiters' import { claudeHasReplayContent, @@ -34,6 +38,10 @@ export function resolveClaudeReplayTurn( observeClaudeCommandLifecycle(session, message, onSettledLate) return null } + if (claudeSessionStateEndsTurn(message)) { + releaseClaudeDispatchesUnansweredAtIdle(session, onSettledLate) + return null + } const envelope = readClaudeMessageEnvelope(message) const isUserReplay = envelope?.role === 'user' && diff --git a/src/main/claude/claude-request-end-wait.test.ts b/src/main/claude/claude-request-end-wait.test.ts new file mode 100644 index 000000000000..7cca7a190dcd --- /dev/null +++ b/src/main/claude/claude-request-end-wait.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + awaitClaudeRequestEnd, + CLAUDE_STOP_GRACE_MS, + claudeStoppedRequestEndWait, + settleClaudeTurnEndWaiters +} from './claude-request-end-wait' +import type { ClaudeSession } from './claude-structured-session-state' + +type InFlight = { translator: { currentTurnId: string | null }; dispatchWaiters: unknown[] } + +// Only what Claude has in flight is read: the wait derives everything else from it. `state` is the +// same object, for the test to move Claude along. +function claudeWith( + currentTurnId: string | null = 'turn-1', + unanswered = 0 +): { claude: ClaudeSession; state: InFlight } { + const state: InFlight = { + translator: { currentTurnId }, + dispatchWaiters: Array.from({ length: unanswered }, () => ({})) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the wait reads only `translator.currentTurnId` and `dispatchWaiters`, and keys its waiters by the session's identity. + return { claude: state as unknown as ClaudeSession, state } +} + +function session(currentTurnId: string | null = 'turn-1'): ClaudeSession { + return claudeWith(currentTurnId).claude +} + +async function settledWithin(promise: Promise, ms: number): Promise { + let settled = false + void promise.then(() => { + settled = true + }) + await vi.advanceTimersByTimeAsync(ms) + return settled +} + +describe("a Stop's wait for Claude to wind down what it has in flight", () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('waits out the whole time for a turn Claude never ends', async () => { + const waiting = awaitClaudeRequestEnd(session(), CLAUDE_STOP_GRACE_MS) + + expect(await settledWithin(waiting, CLAUDE_STOP_GRACE_MS - 1)).toBe(false) + expect(await settledWithin(waiting, 1)).toBe(true) + }) + + it('ends as soon as the stopped turn ends', async () => { + const { claude, state } = claudeWith() + const waiting = awaitClaudeRequestEnd(claude, CLAUDE_STOP_GRACE_MS) + state.translator.currentTurnId = null + settleClaudeTurnEndWaiters(claude) + + expect(await settledWithin(waiting, 0)).toBe(true) + }) + + it('waits on a send Claude has not echoed, through its turn, to that turn’s end', async () => { + const { claude, state } = claudeWith(null, 1) + const waiting = awaitClaudeRequestEnd(claude, CLAUDE_STOP_GRACE_MS) + // The echo answers the send and opens its turn; a settle in between leaves it in flight. + state.dispatchWaiters.length = 0 + state.translator.currentTurnId = 'turn-1' + settleClaudeTurnEndWaiters(claude) + expect(await settledWithin(waiting, 0)).toBe(false) + + state.translator.currentTurnId = null + settleClaudeTurnEndWaiters(claude) + expect(await settledWithin(waiting, 0)).toBe(true) + }) + + it('holds nothing when Claude has nothing in flight', async () => { + expect(await settledWithin(awaitClaudeRequestEnd(session(null), 1_000), 0)).toBe(true) + }) + + it('waits only what the interrupt left of the grace, and nothing for a session that is gone', async () => { + const sessions = new Map([['session-1', session()]]) + const wait = claudeStoppedRequestEndWait(sessions) + const stoppedAt = Date.now() + await vi.advanceTimersByTimeAsync(CLAUDE_STOP_GRACE_MS - 1_000) + const waiting = wait('session-1', stoppedAt) + + expect(await settledWithin(waiting, 999)).toBe(false) + expect(await settledWithin(waiting, 1)).toBe(true) + expect(await settledWithin(wait('session-2', Date.now()), 0)).toBe(true) + }) +}) diff --git a/src/main/claude/claude-request-end-wait.ts b/src/main/claude/claude-request-end-wait.ts new file mode 100644 index 000000000000..a176c2f96f18 --- /dev/null +++ b/src/main/claude/claude-request-end-wait.ts @@ -0,0 +1,81 @@ +// A Stop ends Claude's child. Before it does, Claude gets what is left of the grace to wind down what +// it has in flight through its own path: an unechoed send echoes and opens its turn, the stopped turn +// ends with its own result, and Claude writes both to its transcript. Keyed on Claude's own request, +// not on a journal turn: a Stop pressed before the echo has no turn row yet. + +import type { ClaudeSession } from './claude-structured-session-state' + +/** One budget from the Stop's interrupt: for Claude to answer it and wind its request down. */ +export const CLAUDE_STOP_GRACE_MS = 3_000 + +const waiters = new WeakMap void>>() + +/** Claude has a turn open, or a send it has not answered yet. */ +function claudeRequestInFlight(session: ClaudeSession): boolean { + return (session.translator?.currentTurnId ?? null) !== null || session.dispatchWaiters.length > 0 +} + +function nextSettle(session: ClaudeSession): { settled: Promise; forget: () => void } { + let waiting = waiters.get(session) + if (!waiting) { + waiting = new Set() + waiters.set(session, waiting) + } + const set = waiting + let wake!: () => void + const settled = new Promise((resolve) => { + wake = resolve + }) + set.add(wake) + return { settled, forget: () => set.delete(wake) } +} + +/** Resolves once Claude has nothing in flight, re-read at each settle, or after `ms`. */ +export async function awaitClaudeRequestEnd(session: ClaudeSession, ms: number): Promise { + let elapsed = false + let timer: ReturnType | undefined + const budget = new Promise((resolve) => { + timer = setTimeout(() => { + elapsed = true + resolve() + }, ms) + timer.unref?.() + }) + try { + // A settle that leaves something in flight, such as a subagent's result, waits on. + while (!elapsed && claudeRequestInFlight(session)) { + const next = nextSettle(session) + try { + await Promise.race([next.settled, budget]) + } finally { + next.forget() + } + } + } finally { + clearTimeout(timer) + } +} + +/** The adapter's wait: whatever of the grace the Stop's interrupt left, counted from `stoppedAt`. */ +export function claudeStoppedRequestEndWait( + sessions: Map +): (sessionId: string, stoppedAt: number) => Promise { + return async (sessionId, stoppedAt) => { + const session = sessions.get(sessionId) + if (session) { + await awaitClaudeRequestEnd( + session, + Math.max(0, stoppedAt + CLAUDE_STOP_GRACE_MS - Date.now()) + ) + } + } +} + +/** Something Claude had in flight settled: a result, the CLI's idle, the child's exit or its close. */ +export function settleClaudeTurnEndWaiters(session: ClaudeSession): void { + const waiting = waiters.get(session) + waiters.delete(session) + for (const wake of waiting ?? []) { + wake() + } +} diff --git a/src/main/claude/claude-result-journaling.ts b/src/main/claude/claude-result-journaling.ts index 14fd2e94eb6a..1fe79b626064 100644 --- a/src/main/claude/claude-result-journaling.ts +++ b/src/main/claude/claude-result-journaling.ts @@ -56,13 +56,15 @@ export function journalClaudeResult( } // Read before the settle below closes it: the result reports that turn's end. const endedTurnScope = turn.turnScope + // The stop Orca sent that turn: after the user's own, an error end is their cancellation. + const stop = settlesTurn ? turn.stop : null if (settlesTurn) { prompts.retryPendingCancellations() turn.suppressReopenOnFailure(message.is_error === true) // The turn is over however it ended, so a foreground child still // reported as working will never be settled by an event. subagents.settleTurn(turn.groupKey) - context.settle(message, commandEnd ?? claudeTurnEndForResult(message, observedAt)) + context.settle(message, commandEnd ?? claudeTurnEndForResult(message, observedAt, stop)) // The turn is over. A block still awaiting its final keeps the text the // flush above journaled, but its live state goes: an interrupted turn // would otherwise retain that text for the life of the session. @@ -70,7 +72,7 @@ export function journalClaudeResult( streamedText.settle() } const kind = claudeProviderFrameKind(message) - const failure = claudeResultFailure(message) + const failure = claudeResultFailure(message, stop) if (failure || !isSettledClaudeResultKind(kind)) { providerFallback.append( kind, diff --git a/src/main/claude/claude-result-outcome.ts b/src/main/claude/claude-result-outcome.ts index 5dbfbfffc719..cdbf5a529bfc 100644 --- a/src/main/claude/claude-result-outcome.ts +++ b/src/main/claude/claude-result-outcome.ts @@ -6,6 +6,8 @@ // the user's or the provider's. import type { AgentJournalTurnOutcome } from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { stopIsTheUsers } from '../native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict' import { claudeText } from './claude-structured-item-translation' /** The SDK reports the user's stop as an error result, so `is_error` alone cannot @@ -13,11 +15,18 @@ import { claudeText } from './claude-structured-item-translation' const CLAUDE_ABORTED_TERMINAL_REASONS = new Set(['aborted_streaming', 'aborted_tools']) /** A success-subtype result still carries `is_error` for an API error, so the flag - * is what decides, never the subtype. */ -export function claudeResultOutcome(message: Record): AgentJournalTurnOutcome { + * is what decides, never the subtype. `stop` is the stop Orca sent for this turn: an error + * end after the user's own is their cancellation, since older CLIs name no reason. */ +export function claudeResultOutcome( + message: Record, + stop: StructuredAgentSessionStopCause | null = null +): AgentJournalTurnOutcome { if (message.is_error !== true) { return 'success' } + if (stop !== null && stopIsTheUsers(stop)) { + return 'cancellation' + } const reason = claudeText(message.terminal_reason) return reason !== null && CLAUDE_ABORTED_TERMINAL_REASONS.has(reason) ? 'cancellation' : 'failure' } diff --git a/src/main/claude/claude-slash-command-catalog.test.ts b/src/main/claude/claude-slash-command-catalog.test.ts index f4374e52e4fa..17d7fba8650d 100644 --- a/src/main/claude/claude-slash-command-catalog.test.ts +++ b/src/main/claude/claude-slash-command-catalog.test.ts @@ -245,31 +245,3 @@ it('reports a description-only change and lets a later report drop the text', () expect(catalog.observe({ ...changed, commands: [{ name: 'clear' }] })).toBe(true) expect(catalog.commands).toEqual([{ name: 'clear', kind: 'command' }]) }) - -it('describes nothing when the session reported names only', () => { - expect(readClaudeSlashCommands(init())).toEqual([ - { name: 'clear', kind: 'command' }, - { name: 'ref-oss', kind: 'skill' }, - { name: 'opsx:apply', kind: 'command' } - ]) - expect(new ClaudeSlashCommandCatalog(init()).commands).toEqual([ - { name: 'clear', kind: 'command' }, - { name: 'ref-oss', kind: 'skill' }, - { name: 'opsx:apply', kind: 'command' } - ]) -}) - -it('still hides terminal-only names however well the provider describes them', () => { - const catalog = new ClaudeSlashCommandCatalog(init()) - expect( - catalog.observe({ - type: 'system', - subtype: 'commands_changed', - commands: [ - { name: 'doctor', description: 'Diagnose the CLI install' }, - { name: 'ref-oss', description: 'A skill' } - ] - }) - ).toBe(true) - expect(catalog.commands).toEqual([{ name: 'ref-oss', kind: 'skill', description: 'A skill' }]) -}) diff --git a/src/main/claude/claude-stop-ack-race.test.ts b/src/main/claude/claude-stop-ack-race.test.ts new file mode 100644 index 000000000000..5f2f60d3a692 --- /dev/null +++ b/src/main/claude/claude-stop-ack-race.test.ts @@ -0,0 +1,144 @@ +// A task that finishes on its own just as the user presses Stop keeps its own ending. The CLI +// writes the task's completion before its answer to `stop_task`, but the SDK hands Orca a control +// answer as soon as it reads it and queues every other frame, so the acknowledgement can arrive +// first. Driven through the real SDK and Orca's stream-json connection against the scripted CLI. + +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { spawnProcess } from '../../shared/child-process/run-process' +import { createAgentChildWorkAdmission } from '../../shared/agent-status-child-work-admission' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' +import { reconcileAgentChildWorkEvidence } from '../../shared/agent-status-child-work-reconciliation' +import { createAgentStatusStore } from '../../shared/agent-status-store' +import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' +import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' +import { + openClaudeStreamJsonConnection, + type ClaudeStreamJsonConnection +} from './claude-stream-json-connection' +import { stopClaudeBackgroundTasks } from './claude-structured-control-actions' +import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution' +import type { ClaudeSession } from './claude-structured-session-state' + +const FAKE_CLI = join(__dirname, '__fixtures__', 'claude-agent-sdk-scripted-cli.mjs') +const SESSION_ID = '5348c19f-6a54-4c2e-9c68-9c2b1a3d4e5f' +const parent = makeStructuredAgentStatusSubject( + { executionHostId: 'local', wslDistro: null, workspaceId: 'ws-1', workspaceKind: 'folder' }, + 'session-1' +) +const STARTED = { + type: 'system', + subtype: 'task_started', + task_id: 'task-1', + tool_use_id: 'toolu_1', + task_type: 'local_bash', + is_backgrounded: true, + description: 'npm test', + session_id: SESSION_ID, + uuid: 'started-1' +} +const COMPLETED = { + type: 'system', + subtype: 'task_notification', + task_id: 'task-1', + tool_use_id: 'toolu_1', + status: 'completed', + summary: 'all 12 tests passed', + session_id: SESSION_ID, + uuid: 'completed-1' +} + +const scratch: string[] = [] +const connections: ClaudeStreamJsonConnection[] = [] +afterEach(async () => { + for (const connection of connections.splice(0)) { + await connection.close() + } + for (const dir of scratch.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +/** Streamed text the CLI wrote ahead of the completion, as while Claude is answering. */ +function streamed(count: number): Record[] { + return Array.from({ length: count }, (_, index) => ({ + type: 'stream_event', + event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'x' } }, + session_id: SESSION_ID, + uuid: `stream-${index}` + })) +} + +async function until(done: () => boolean): Promise { + for (let attempt = 0; attempt < 500 && !done(); attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)) + } + expect(done()).toBe(true) +} + +/** The task's record after the user stops it and the CLI answers behind `ahead` streamed frames + * and the task's own completion, all in one write. */ +async function stoppedAsItCompleted(ahead: number) { + const dir = mkdtempSync(join(tmpdir(), 'claude-stop-ack-race-')) + scratch.push(dir) + const scenarioPath = join(dir, 'scenario.json') + writeFileSync( + scenarioPath, + JSON.stringify({ + steps: [{ emit: STARTED }, { delayMs: 10_000 }], + controlResponses: { stop_task: {} }, + controlResponseLeadingFrames: { stop_task: [...streamed(ahead), COMPLETED] } + }) + ) + const decoder = new ClaudeChildWorkDecoder() + const evidence: AgentChildWorkEvidence[] = [] + const seen: string[] = [] + const connection = await openClaudeStreamJsonConnection( + { + pathToClaudeCodeExecutable: FAKE_CLI, + options: { ...CLAUDE_STRUCTURED_BASE_OPTIONS, sessionId: SESSION_ID }, + cwd: dir, + env: { + PATH: process.env.PATH ?? '', + ORCA_SDK_CONTRACT_SCENARIO_PATH: scenarioPath, + ORCA_SDK_CONTRACT_REPORT_PATH: join(dir, 'report.json') + } + }, + { + onMessage: (message) => { + seen.push(`${String(message.type)}:${String(message.subtype ?? '')}`) + decoder.observe(message) + evidence.push(...decoder.drain(seen.length)) + } + }, + spawnProcess + ) + connections.push(connection) + await until(() => seen.includes('system:task_started')) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a background stop reads only the session's connection and its child-work decoder. + const session = { connection, childWork: decoder } as unknown as ClaudeSession + await stopClaudeBackgroundTasks(session, 5_000, () => true, ['task-1']) + evidence.push(...decoder.drain(seen.length)) + await until(() => seen.includes('system:task_notification')) + + const store = createAgentStatusStore({ epoch: 'epoch-1', mode: 'authority' }) + store.applyMutation({ parent: { subject: parent } }) + let minted = 0 + const admission = createAgentChildWorkAdmission(store, { + mintChildWorkId: () => `child-${++minted}` + }) + reconcileAgentChildWorkEvidence({ store, admission, parent, provider: 'claude', evidence }) + return store.getChildren(parent) +} + +describe('a Stop acknowledged ahead of the task’s own earlier completion', () => { + for (const ahead of [0, 1, 5, 50]) { + it(`keeps the task's own outcome and summary with ${ahead} frames ahead of it`, async () => { + expect(await stoppedAsItCompleted(ahead)).toMatchObject([ + { membership: 'settled', outcome: 'succeeded', lastMessage: 'all 12 tests passed' } + ]) + }, 20_000) + } +}) diff --git a/src/main/claude/claude-streamed-text-checkpoints.test.ts b/src/main/claude/claude-streamed-text-checkpoints.test.ts index cc196cf21af8..89f825bc5605 100644 --- a/src/main/claude/claude-streamed-text-checkpoints.test.ts +++ b/src/main/claude/claude-streamed-text-checkpoints.test.ts @@ -216,23 +216,6 @@ describe('claude streamed text checkpoints', () => { expect(stamps).toEqual([CHILD_LINKAGE, { ...CHILD_LINKAGE, agentId: 'task-2' }]) }) - it('re-attributes a block that stopped streaming before its announcement', () => { - // Nothing revisits such a block: no later checkpoint, no final envelope. - // Without this it keeps the provisional id for the life of the journal. - const producer = scriptedProducer() - const { store, rows, stamps, runWindow } = checkpoints(producer.source) - - store.append(identityOf('block-1'), 'said once', 'toolu_1') - runWindow() - expect(stamps.at(-1)).toMatchObject({ agentId: 'toolu_1' }) - - producer.resolve(CHILD_LINKAGE) - store.reattribute() - - expect(rows.at(-1)).toEqual({ uuid: 'block-1', text: 'said once' }) - expect(stamps.at(-1)).toEqual(CHILD_LINKAGE) - }) - it('stops persisting once disposed', () => { const { store, rows, runWindow } = checkpoints() diff --git a/src/main/claude/claude-structured-acquisition-launch.ts b/src/main/claude/claude-structured-acquisition-launch.ts index 05d2d39fbd3d..b3764b16d99e 100644 --- a/src/main/claude/claude-structured-acquisition-launch.ts +++ b/src/main/claude/claude-structured-acquisition-launch.ts @@ -1,9 +1,9 @@ import { AgentSessionAcquisitionExitUnprovenError, AgentSessionPreSpawnError, - stopAgentSessionProviderRoot, type StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { stopAgentSessionProviderRoot } from '../native-chat/agent-session-wire/structured-agent-session-provider-exit-proof' import { withAgentSessionCreatePhase } from '../observability/agent-session-instrumentation' import type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' import { diff --git a/src/main/claude/claude-structured-acquisition-release.ts b/src/main/claude/claude-structured-acquisition-release.ts index 3df76971df16..d29879bb73ee 100644 --- a/src/main/claude/claude-structured-acquisition-release.ts +++ b/src/main/claude/claude-structured-acquisition-release.ts @@ -23,7 +23,6 @@ export async function releaseClaudeAcquisition(input: { onExitProven?: (sessionId: string, exit: ClaudeSessionExit) => Promise persistHandle?: ClaudeStructuredSessionAdapterDeps['persistHandle'] onEvent?: ClaudeStructuredSessionAdapterDeps['onEvent'] - onBackgroundTasksChanged?: ClaudeStructuredSessionAdapterDeps['onBackgroundTasksChanged'] }): Promise { const exit = input.exits.get(input.sessionId) if (!exit || input.sessions.has(input.sessionId) || input.acquisitions.get(input.sessionId)) { diff --git a/src/main/claude/claude-structured-child-work-captures.test.ts b/src/main/claude/claude-structured-child-work-captures.test.ts index d69ba1208466..4ab9dd61b620 100644 --- a/src/main/claude/claude-structured-child-work-captures.test.ts +++ b/src/main/claude/claude-structured-child-work-captures.test.ts @@ -87,7 +87,7 @@ describe('Claude child work from captured frame orders', () => { }) }) - it('settles what still runs when the session ends, and keeps every record', async () => { + it('stops what still runs when Orca ends the session, and keeps every record', async () => { const run = await producer(hostWithParent()) const [running] = until(MOVED_TO_BACKGROUND, 51_275) run.replay(running) @@ -99,8 +99,37 @@ describe('Claude child work from captured frame orders', () => { outcome })) ).toEqual([ - { description: 'Run 45s sleep command', membership: 'settled', outcome: 'unknown' }, - { description: 'Sleep 45 seconds then print 1', membership: 'settled', outcome: 'unknown' } + { description: 'Run 45s sleep command', membership: 'settled', outcome: 'cancelled' }, + { description: 'Sleep 45 seconds then print 1', membership: 'settled', outcome: 'cancelled' } + ]) + }) + + it('leaves how they ended unknown when the close sees a descendant survive', async () => { + const run = await producer(hostWithParent()) + const [running] = until(MOVED_TO_BACKGROUND, 51_275) + run.replay(running) + // The root exited, but Orca's own tree check saw a process of the session's still running. + const connection = run.claude.connections[0]! + connection.exitVerdict = { root: 'exited', tree: 'live' } + connection.close = async () => false + await expect(run.adapter.closeSession('session-1', 'user-stop')).rejects.toMatchObject({ + name: 'AgentSessionAcquisitionRootExitObservedError' + }) + expect(run.records().map(({ membership, outcome }) => ({ membership, outcome }))).toEqual([ + { membership: 'settled', outcome: 'unknown' }, + { membership: 'settled', outcome: 'unknown' } + ]) + }) + + it('leaves how they ended unknown when the session dies on its own', async () => { + const run = await producer(hostWithParent()) + const [running] = until(MOVED_TO_BACKGROUND, 51_275) + run.replay(running) + run.claude.connections[0]!.handlers.onExit?.(new Error('claude exited')) + await run.adapter.drainObservedExits() + expect(run.records().map(({ membership, outcome }) => ({ membership, outcome }))).toEqual([ + { membership: 'settled', outcome: 'unknown' }, + { membership: 'settled', outcome: 'unknown' } ]) }) diff --git a/src/main/claude/claude-structured-child-work-producer.test.ts b/src/main/claude/claude-structured-child-work-producer.test.ts index d47d07b43355..86d1d9a7ac46 100644 --- a/src/main/claude/claude-structured-child-work-producer.test.ts +++ b/src/main/claude/claude-structured-child-work-producer.test.ts @@ -16,7 +16,7 @@ vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => afterEach(() => vi.restoreAllMocks()) describe('Claude structured child-work producer', () => { - it('delivers evidence only after the journal wrote the frame and the legacy row republished', async () => { + it('delivers evidence only after the journal wrote and published the frame', async () => { const { send, records } = await producer() send(toolUse('toolu_bg', 'Agent', { description: 'Audit the build' })) const deliveries = send( @@ -32,7 +32,9 @@ describe('Claude structured child-work producer', () => { const kinds = deliveries.map((delivery) => delivery.kind) // The frame's own rows, then the parent's republished row, and only then its children. expect(kinds.filter((kind) => kind === 'journal').length).toBeGreaterThan(0) - expect(kinds.slice(kinds.indexOf('legacy'))).toEqual(['legacy', 'evidence']) + expect(kinds.lastIndexOf('publish')).toBeGreaterThan(kinds.lastIndexOf('journal')) + expect(kinds.at(-1)).toBe('evidence') + expect(kinds.filter((kind) => kind === 'evidence')).toHaveLength(1) expect(records()).toEqual([ expect.objectContaining({ description: 'Audit the build', membership: 'live' }) ]) @@ -193,7 +195,7 @@ describe('Claude structured child-work producer', () => { step.check?.() } await adapter.closeSession('session-1') - // The session is gone: what still ran settles unreported, and every record stays. + // Orca ended the session: what still ran is stopped with it, and every record stays. const summary = records().map((record) => ({ description: record.description, membership: record.membership, @@ -208,7 +210,7 @@ describe('Claude structured child-work producer', () => { generation: 1 }, { description: 'npm test', membership: 'settled', outcome: 'cancelled', generation: 1 }, - { description: 'Audit the build', membership: 'settled', outcome: 'unknown', generation: 2 } + { description: 'Audit the build', membership: 'settled', outcome: 'cancelled', generation: 2 } ]) expect(adapter.backgroundTaskState('session-1')).toBeUndefined() }) diff --git a/src/main/claude/claude-structured-content-parts.test.ts b/src/main/claude/claude-structured-content-parts.test.ts index 1d9ed800e0f8..312c1c10826c 100644 --- a/src/main/claude/claude-structured-content-parts.test.ts +++ b/src/main/claude/claude-structured-content-parts.test.ts @@ -121,18 +121,6 @@ describe('Claude message content parts', () => { expect(state.sink.publish).not.toHaveBeenCalled() }) - it('does not render injected image companions or start a turn', () => { - const state = sinkState() - const translator = createClaudeJournalTranslator({ sink: state.sink }) - const event = userMessageWith(null) - const content = [{ type: 'text', text: '[Image: source: /tmp/pasted.png]' }] - translator.handle({ - ...event, - message: { ...event.message, isMeta: true, message: { role: 'user', content } } - }) - expect(state.items).toEqual([]) - }) - it('does not leak a wire kind for a locally attached image', () => { const state = sinkState() const translator = createClaudeJournalTranslator({ sink: state.sink }) diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index 4ba08d3b642c..b8629af1f3f8 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -6,9 +6,13 @@ import { } from './claude-structured-control-actions' import { dispatchClaudeTurn } from './claude-structured-dispatch' import { ClaudeControlRequestError } from './claude-stream-json-connection' +import { + ClaudeControlRequestTimeoutError, + runClaudeControl +} from './claude-agent-sdk-control-requests' import { buildClaudePromptReply, ClaudePromptRegistry } from './claude-structured-prompt-replies' import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-session-state' -import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' +import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' import { sessionFor, userMessage } from './claude-structured-dispatch-test-support' type InterruptResult = Awaited> @@ -193,7 +197,7 @@ describe('cancelClaudeTurn', () => { }) describe('answerClaudePrompt', () => { - it('resolves cancellation observation when teardown clears the prompt registry', async () => { + it('forgets a pending prompt and its claim when teardown clears the registry', () => { const prompts = new ClaudePromptRegistry() const settle = vi.fn() const prompt = prompts.register({ @@ -209,19 +213,8 @@ describe('answerClaudePrompt', () => { if (!claim) { throw new Error('expected prompt claim') } - const observed = prompts.observeCancellation(claim) - if (!observed) { - throw new Error('expected cancellation observation') - } - let observedCancellation = false - void observed.then(() => { - observedCancellation = true - }) expect(prompts.clear()).toEqual([prompt]) - await Promise.resolve() - - expect(observedCancellation).toBe(true) expect(prompts.find('journal-clear')).toBeNull() expect(prompts.ownsClaim(claim)).toBe(false) expect(settle).not.toHaveBeenCalled() @@ -245,10 +238,12 @@ describe('answerClaudePrompt', () => { handle: vi.fn(), openTurnInLiveProviderCycle: false, journalPrompts: { - cancel: vi.fn(() => ({ accepted: true as const })), - resolve: resolvePrompt + resolve: resolvePrompt, + handOver: () => () => {}, + cancel: () => ({ accepted: true }) }, currentTurnId: null, + recordTurnStop: () => true, commandTurnId: null, beginCommand: vi.fn(), forgetCommand: vi.fn(), @@ -288,82 +283,126 @@ describe('answerClaudePrompt', () => { }) describe('stopClaudeBackgroundTasks', () => { - it('stops each live SDK task id and never depends on an active turn id', async () => { - const backgroundTasks = new ClaudeBackgroundTaskTracker() - backgroundTasks.observe({ - type: 'system', - subtype: 'background_tasks_changed', - tasks: [ - { task_id: 'task-agent', task_type: 'local_agent', description: 'agent' }, - { task_id: 'task-bash', task_type: 'local_bash', description: 'bash' } - ] - }) + /** A decoder holding `ids` live, as backgrounded agents. */ + function liveChildWork(ids: string[]): ClaudeChildWorkDecoder { + const childWork = new ClaudeChildWorkDecoder() + for (const id of ids) { + childWork.observe({ + type: 'system', + subtype: 'task_started', + task_id: id, + task_type: 'local_agent', + is_backgrounded: true + }) + } + childWork.drain(1) + return childWork + } + + function stoppingSession( + childWork: ClaudeChildWorkDecoder, + stopTask: (taskId: string, options?: { timeoutMs?: number }) => Promise + ): ClaudeSession { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a background stop reads only the session's child-work decoder and its connection's stopTask. + return { childWork, connection: { stopTask } } as unknown as ClaudeSession + } + + it('stops exactly the ids the host named, and ends each acknowledged one', async () => { + const childWork = liveChildWork(['task-agent', 'task-bash']) const stopTask = vi.fn(async (_taskId: string, _options?: { timeoutMs?: number }) => {}) - const session = { backgroundTasks, connection: { stopTask } } as unknown as ClaudeSession + const session = stoppingSession(childWork, stopTask) - await expect(stopClaudeBackgroundTasks(session, 5_000)).resolves.toEqual({ cancelled: true }) + await expect( + stopClaudeBackgroundTasks(session, 5_000, () => true, ['task-agent', 'task-bash']) + ).resolves.toEqual({ cancelled: true }) expect(stopTask.mock.calls).toEqual([ ['task-agent', { timeoutMs: 5_000 }], ['task-bash', { timeoutMs: 5_000 }] ]) + expect(childWork.drain(2)).toEqual([ + { + type: 'ended', + observedAt: 2, + handle: { idKind: 'task_id', id: 'task-agent' }, + outcome: 'cancelled', + basis: 'stop-acknowledged' + }, + { + type: 'ended', + observedAt: 2, + handle: { idKind: 'task_id', id: 'task-bash' }, + outcome: 'cancelled', + basis: 'stop-acknowledged' + } + ]) }) it('stops issuing requests when ownership changes between tasks', async () => { - const backgroundTasks = new ClaudeBackgroundTaskTracker() - for (const taskId of ['task-1', 'task-2']) { - backgroundTasks.observe({ - type: 'system', - subtype: 'task_started', - task_id: taskId, - task_type: 'local_agent', - is_backgrounded: true - }) - } + const childWork = liveChildWork(['task-1', 'task-2']) let current = true const stopTask = vi.fn(async (_taskId: string) => { current = false }) - const session = { backgroundTasks, connection: { stopTask } } as unknown as ClaudeSession + const session = stoppingSession(childWork, stopTask) - await stopClaudeBackgroundTasks(session, undefined, () => current) + await stopClaudeBackgroundTasks(session, undefined, () => current, ['task-1', 'task-2']) expect(stopTask).toHaveBeenCalledTimes(1) }) - it('stops only the requested live task id', async () => { - const backgroundTasks = new ClaudeBackgroundTaskTracker() - backgroundTasks.observe({ - type: 'system', - subtype: 'background_tasks_changed', - tasks: [ - { task_id: 'task-one', task_type: 'local_agent' }, - { task_id: 'task-two', task_type: 'local_bash' } - ] + it('leaves a task live when the CLI refuses its stop, and makes no call for no ids', async () => { + const childWork = liveChildWork(['task-live']) + const stopTask = vi.fn(async (_taskId: string) => { + throw new ClaudeControlRequestError('stop_task', 'Task task-live is owned by another agent') }) - const stopTask = vi.fn(async (_taskId: string) => {}) - const session = { backgroundTasks, connection: { stopTask } } as unknown as ClaudeSession + const session = stoppingSession(childWork, stopTask) await expect( - stopClaudeBackgroundTasks(session, 5_000, () => true, 'task-two') - ).resolves.toEqual({ cancelled: true }) - expect(stopTask).toHaveBeenCalledWith('task-two', { timeoutMs: 5_000 }) + stopClaudeBackgroundTasks(session, undefined, () => true, ['task-live']) + ).resolves.toEqual({ cancelled: false }) + expect(childWork.drain(2)).toEqual([]) + await expect(stopClaudeBackgroundTasks(session, undefined, () => true, [])).resolves.toEqual({ + cancelled: false + }) expect(stopTask).toHaveBeenCalledTimes(1) }) - it('refuses a stale or unknown task id without a provider call', async () => { - const backgroundTasks = new ClaudeBackgroundTaskTracker() - backgroundTasks.observe({ - type: 'system', - subtype: 'task_started', - task_id: 'task-live', - task_type: 'local_agent', - is_backgrounded: true + it('stops the remaining tasks after one request fails, then reports the failure', async () => { + const childWork = liveChildWork(['task-1', 'task-2']) + const lost = new Error('connection reset') + const stopTask = vi.fn(async (taskId: string) => { + if (taskId === 'task-1') { + throw lost + } }) - const stopTask = vi.fn(async (_taskId: string) => {}) - const session = { backgroundTasks, connection: { stopTask } } as unknown as ClaudeSession + const session = stoppingSession(childWork, stopTask) await expect( - stopClaudeBackgroundTasks(session, undefined, () => true, 'task-stale') - ).resolves.toEqual({ cancelled: false }) - expect(stopTask).not.toHaveBeenCalled() + stopClaudeBackgroundTasks(session, undefined, () => true, ['task-1', 'task-2']) + ).rejects.toBe(lost) + expect(stopTask.mock.calls.map(([taskId]) => taskId)).toEqual(['task-1', 'task-2']) + expect(childWork.drain(2)).toMatchObject([ + { type: 'ended', handle: { id: 'task-2' }, outcome: 'cancelled' } + ]) + }) + + it('stops asking after a request times out: a CLI not answering costs one deadline, not one per task', async () => { + const childWork = liveChildWork(['task-0', 'task-1', 'task-2', 'task-3', 'task-4']) + // Never answered, behind the real deadline wrapper, with a short deadline. + const stopTask = vi.fn((_taskId: string, options?: { timeoutMs?: number }) => + runClaudeControl('stop_task', () => new Promise(() => {}), options?.timeoutMs) + ) + const session = stoppingSession(childWork, stopTask) + + await expect( + stopClaudeBackgroundTasks(session, 50, () => true, [ + 'task-0', + 'task-1', + 'task-2', + 'task-3', + 'task-4' + ]) + ).rejects.toBeInstanceOf(ClaudeControlRequestTimeoutError) + expect(stopTask).toHaveBeenCalledTimes(1) + expect(childWork.drain(2)).toEqual([]) }) }) diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts index 8ec1aa2e9c41..adf4d866fc36 100644 --- a/src/main/claude/claude-structured-control-actions.ts +++ b/src/main/claude/claude-structured-control-actions.ts @@ -1,9 +1,11 @@ import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk' import type { ClaudePromptClaim } from './claude-structured-prompt-replies' import { ClaudeControlRequestError } from './claude-stream-json-connection' +import { ClaudeControlRequestTimeoutError } from './claude-agent-sdk-control-requests' import { settleCancelledClaudeDispatchWaiters } from './claude-structured-dispatch' import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution' import type { ClaudeSession } from './claude-structured-session-state' +import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' const INTERRUPT_CANCEL_QUEUED_CAPABILITY = 'interrupt_cancel_queued_v1' @@ -25,7 +27,8 @@ export async function cancelClaudeTurn( session: ClaudeSession, timeoutMs: number | undefined, isCurrent: ClaudeTurnCancellationGuard = () => true, - onDispatchSettledLate?: ClaudeLateDispatchSettlement + onDispatchSettledLate?: ClaudeLateDispatchSettlement, + stopped?: { turnId: string; cause: StructuredAgentSessionStopCause } ): Promise<{ cancelled: boolean }> { // The SDK interrupt is session-scoped. Re-check the caller's turn/fence // immediately before issuing it so a delayed request cannot stop a later turn. @@ -33,6 +36,10 @@ export async function cancelClaudeTurn( return { cancelled: false } } const cancelQueued = supportsClaudeQueuedInterruptCancellation(session) + // Recorded before the interrupt goes out, so the result it provokes finds it. + if (stopped) { + session.translator?.recordTurnStop(stopped.turnId, stopped.cause) + } try { const receipt = await session.connection.interrupt({ ...(cancelQueued ? { cancelQueued: true } : {}), @@ -51,6 +58,8 @@ export async function cancelClaudeTurn( } return { cancelled: true } } catch (error) { + // The CLI refused. Any other error leaves the interrupt's effect unknown. Either way the Stop + // ends the child next, so the stop recorded on the turn stands. if (error instanceof ClaudeControlRequestError) { return { cancelled: false } } @@ -58,29 +67,39 @@ export async function cancelClaudeTurn( } } +/** Stops each task the host named. An acknowledged stop ends the task's record: the CLI answers + * success for a task it no longer knows without sending that task any frame. */ export async function stopClaudeBackgroundTasks( session: ClaudeSession, timeoutMs: number | undefined, - isCurrent: ClaudeTurnCancellationGuard = () => true, - taskId?: string + isCurrent: ClaudeTurnCancellationGuard, + taskIds: readonly string[] ): Promise<{ cancelled: boolean }> { - const stoppableTaskIds = session.backgroundTasks.stoppableTaskIds - const taskIds = - taskId === undefined ? stoppableTaskIds : stoppableTaskIds.includes(taskId) ? [taskId] : [] let cancelled = false + // A failed request for one task still leaves the others to stop; it is reported after them. A + // timeout ends the loop: a CLI that is not answering would make each id wait out its own + // deadline while the session's other actions queue behind this one. + let failure: { error: unknown } | undefined for (const taskId of taskIds) { if (!isCurrent()) { break } try { await session.connection.stopTask(taskId, { timeoutMs }) + session.childWork.stopAcknowledged(taskId) cancelled = true } catch (error) { - if (!(error instanceof ClaudeControlRequestError)) { + if (error instanceof ClaudeControlRequestTimeoutError) { throw error } + if (!(error instanceof ClaudeControlRequestError)) { + failure ??= { error } + } } } + if (failure) { + throw failure.error + } return { cancelled } } diff --git a/src/main/claude/claude-structured-dispatch-waiters.ts b/src/main/claude/claude-structured-dispatch-waiters.ts index 92b6a5048b2f..2c5df3c671eb 100644 --- a/src/main/claude/claude-structured-dispatch-waiters.ts +++ b/src/main/claude/claude-structured-dispatch-waiters.ts @@ -13,8 +13,10 @@ export function forgetRetiredWaiter(session: ClaudeSession, waiter: ClaudeDispat * A waiter with no deadline. A mid-turn send Claude folds into the running turn * is replayed mid-turn; one it runs later is replayed only when its own turn * starts — an interval bounded only by the previous turn. Elapsed time is - * therefore not evidence about delivery, and nothing here expires. Waiters are - * retired by process facts instead: a failed write, or child exit. + * therefore not evidence about delivery, and nothing here expires. Waiters end + * by provider and process facts instead: an echo, a `command_lifecycle` frame, + * the CLI's idle after `started` (`claude-command-lifecycle.ts`), a failed + * write, or child exit. */ export function waitForReplay( session: ClaudeSession, diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index c208cceccdbf..87e706ddbbc3 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -7,7 +7,7 @@ import { } from './claude-structured-dispatch-waiters' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import type { ClaudeSession } from './claude-structured-session-state' +import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-session-state' import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution' import { claudeDispatchContentKey, @@ -18,7 +18,10 @@ import { } from './claude-structured-dispatch-content' import { dispatchWriteOutcomeUnknownReason } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' import { DISPATCH_REJECTED_QUEUE_FULL } from '../../shared/structured-agent-session-dispatch-rejection' -import { agentSessionFailureFact } from '../../shared/agent-session-failure' +import { + agentSessionFailureFact, + type SubmissionRejectionFact +} from '../../shared/agent-session-failure' import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' import { claudeUnwrittenUserMessageError, @@ -37,10 +40,25 @@ export function settleCancelledClaudeDispatchWaiters( cancelledUuids: readonly string[], onSettledLate?: ClaudeLateDispatchSettlement ): void { - const cancelled = new Set(cancelledUuids) - const activeWaiters = session.dispatchWaiters.filter((waiter) => cancelled.has(waiter.sentUuid)) + rejectClaudeDispatchWaiters( + session, + cancelledUuids, + agentSessionFailureFact('cancelled'), + onSettledLate + ) +} + +/** Settles sends the CLI proved it will never run, each with the fact that says why. */ +export function rejectClaudeDispatchWaiters( + session: ClaudeSession, + uuids: readonly string[], + fact: SubmissionRejectionFact, + onSettledLate?: ClaudeLateDispatchSettlement +): void { + const named = new Set(uuids) + const activeWaiters = session.dispatchWaiters.filter((waiter) => named.has(waiter.sentUuid)) const retiredWaiters = session.retiredDispatchWaiters.filter((waiter) => - cancelled.has(waiter.sentUuid) + named.has(waiter.sentUuid) ) for (const waiter of activeWaiters) { forgetWaiter(session, waiter) @@ -54,14 +72,31 @@ export function settleCancelledClaudeDispatchWaiters( onSettledLate?.({ clientMessageId: waiter.clientMessageId, state: 'rejected', - ...claudeDispatchRejection(agentSessionFailureFact('cancelled')) + ...claudeDispatchRejection(fact) }) } } } -/** Nothing expires a waiter, so the child's death is what ends every live one. - * Retired rather than dropped: their identities stay joinable, bounded by +/** Releases sends the CLI took and let go without an echo or a verdict: each may have run, so each + * settles as doubt, never re-sent. Retired, not dropped, so a late replay still accepts it. */ +export function releaseClaudeDispatchWaitersInDoubt( + session: ClaudeSession, + waiters: readonly ClaudeDispatchWaiter[], + reason: string, + onSettledLate?: ClaudeLateDispatchSettlement +): void { + for (const waiter of waiters) { + retireWaiter(session, waiter) + waiter.resolve(null) + if (waiter.clientMessageId) { + onSettledLate?.({ clientMessageId: waiter.clientMessageId, state: 'unknown', reason }) + } + } +} + +/** Nothing expires a waiter; the child's death ends every one its echo, a lifecycle frame or the + * CLI's idle has not. Retired rather than dropped: their identities stay joinable, bounded by * `MAX_RETIRED_DISPATCH_WAITERS`. */ export function retireClaudeDispatchWaiters(session: ClaudeSession): void { failClaudeStartup(session, new Error('claude stream-json ended before startup completed')) diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index 2175ef49e435..0228b1171683 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -14,6 +14,7 @@ import { agentModelCatalogFingerprintForRecord } from '../native-chat/agent-mode import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import type { StructuredAgentSessionMutationContext } from '../native-chat/agent-session-wire/structured-agent-session-host-mutations' import { readStructuredAgentSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-options-read' +import { composeCodexSessionOptionCatalog } from '../codex/codex-structured-model-catalog' import { nativeSessionOptionsFromReport } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' import { ClaudeStructuredSessionAdapter, @@ -55,20 +56,22 @@ const CATALOG = [ } ] -/** get_settings with nothing overriding the CLI's own default; an effort write moves both views. */ -function settingsWithNoOverride() { +/** get_settings with nothing overriding the CLI's own default; an effort write moves both views. + * `model` is what the CLI says it runs, and `listing` the models it lists. */ +function settingsWithNoOverride(model = 'claude-opus-5-5[1m]', listing: unknown[] = CATALOG) { const effective: { effortLevel?: string } = {} const settings = { effective, sources: [], - applied: { model: 'claude-opus-5-5[1m]', effort: 'medium', advisor: null, ultracode: false } + applied: { model, effort: 'medium', advisor: null, ultracode: false } } const claude = fakeClaude({ initProof: 'session-start', - initModels: CATALOG, + initModel: model, + initModels: listing, settings, routes: { - list_models: () => CATALOG, + list_models: () => listing, apply_flag_settings: (params) => { const written = params?.settings const effort = @@ -88,7 +91,8 @@ function settingsWithNoOverride() { async function startChild( store: AgentModelCatalogStore, options?: Record, - events: ClaudeStructuredSessionEvent[] = [] + events: ClaudeStructuredSessionEvent[] = [], + cli: { runs?: string; lists?: unknown[] } = {} ): Promise { const adapter = new ClaudeStructuredSessionAdapter({ resolveLaunch: async () => ({ @@ -102,7 +106,7 @@ async function startChild( continuesChain: false }), onEvent: (event) => events.push(event), - openConnection: settingsWithNoOverride().openConnection, + openConnection: settingsWithNoOverride(cli.runs, cli.lists).openConnection, readProcessStartTime: async () => 1_700_000_000_000, now: () => 1_700_000_000_500, persistHandle: async () => {}, @@ -152,10 +156,10 @@ function readAtRest(store: AgentModelCatalogStore, record: AgentSessionRecord) { return readStructuredAgentSessionOptions(context, SESSION) } -function pickerEffort(result: Awaited>) { - const seed = getAgentSessionOptionCatalog('claude')! +function pickerEffort(result: Awaited>, agent = 'claude') { + const seed = getAgentSessionOptionCatalog(agent)! const state = applyStructuredAgentSessionOptions( - createStructuredAgentSessionOptionState('claude', seed), + createStructuredAgentSessionOptionState(agent, seed), seed, result ) @@ -206,6 +210,115 @@ describe('Claude effort default at rest', () => { expect(catalogDefault(other, 'opus[1m]')).toBe('medium') }) + it('offers no effort for a model the catalog does not list, at rest as live', async () => { + const store = new AgentModelCatalogStore() + const unlisted = { model: 'claude-unlisted-9', effort: 'medium' } + // The CLI runs a model its own catalog does not list, as a newer or pinned model can be. + const child = await startChild(store, unlisted, [], { runs: unlisted.model }) + const live = await child.readOptions({ sessionId: SESSION, fence: 7 }) + + const resting = await readAtRest(store, restingRecord(unlisted)) + + const row = (result: typeof resting) => + result.models.find((entry) => entry.id === unlisted.model) + expect(row(live)).toEqual(expect.objectContaining({ id: unlisted.model, efforts: [] })) + expect(row(resting)).toEqual(row(live)) + expect(pickerEffort(live)).toBeUndefined() + expect(pickerEffort(resting)).toBeUndefined() + }) + + it('lists an unlisted Codex model at rest exactly as its live child does', async () => { + const store = new AgentModelCatalogStore() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read and the catalog key touch only these fields. + const record = { + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' }, + location: { wslDistro: null }, + options: { model: 'gpt-unlisted', effort: 'high' } + } as unknown as AgentSessionRecord + const listing = { + models: [ + { + id: 'gpt-5.5', + label: 'GPT-5.5', + isDefault: true, + efforts: [ + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ] + } + ], + fastModeTierByModel: new Map() + } + store.recordSuccess(agentModelCatalogFingerprintForRecord(record), 'codex', { + ...listing, + origin: 'live-session' + }) + + const resting = await readAtRest(store, record) + const live = composeCodexSessionOptionCatalog(listing, { + current: { model: 'gpt-unlisted', effort: 'high' } + }).result + + expect(resting.models).toEqual(live.models) + expect(live.models.find((entry) => entry.id === 'gpt-unlisted')).toMatchObject({ efforts: [] }) + expect(pickerEffort(resting, 'codex')).toBeUndefined() + }) + + it("keeps a Claude model's effort at rest with no catalog yet, as a live child with none does", async () => { + const saved = { model: 'opus', effort: 'high' } + // A CLI that lists nothing: the live child falls back to Claude's built-in models. + const child = await startChild(new AgentModelCatalogStore(), saved, [], { + runs: 'opus', + lists: [] + }) + const live = await child.readOptions({ sessionId: SESSION, fence: 7 }) + + // An account the host has no catalog for yet. + const resting = await readAtRest(new AgentModelCatalogStore(), restingRecord(saved)) + + expect(resting.models).toEqual(live.models) + expect(pickerEffort(live)).toBe('high') + expect(pickerEffort(resting)).toBe('high') + }) + + it('names no model at rest with no pick and no catalog, so the client keeps what the child ran', async () => { + // The CLI's default model, reported live; the chat never picked one. + const child = await startChild(new AgentModelCatalogStore(), undefined, [], { + runs: 'opus', + lists: [] + }) + const live = await child.readOptions({ sessionId: SESSION, fence: 7 }) + + const resting = await readAtRest(new AgentModelCatalogStore(), restingRecord({})) + + // The built-in list's default is a guess, not this account's: nothing to name. + expect(resting.current.model).toBe('') + const seed = getAgentSessionOptionCatalog('claude')! + const state = [live, resting].reduce( + (current, answer) => applyStructuredAgentSessionOptions(current, seed, answer), + createStructuredAgentSessionOptionState('claude', seed) + ) + const model = structuredAgentSessionOptionSnapshot(state).find((row) => row.id === 'model') + expect(model?.kind.type === 'select' ? model.kind.currentValue : undefined).toBe('opus') + }) + + it("keeps a Codex model's effort at rest with no catalog yet, from the client's own defaults", async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read and the catalog key touch only these fields. + const record = { + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' }, + location: { wslDistro: null }, + options: { model: 'gpt-5.5', effort: 'high' } + } as unknown as AgentSessionRecord + + const resting = await readAtRest(new AgentModelCatalogStore(), record) + + // A Codex child lists nothing without its catalog, so the answer leaves the list to the client. + expect(resting.models).toEqual([]) + expect(pickerEffort(resting, 'codex')).toBe('high') + }) + it("leaves a Codex chat's unsaved effort blank at rest, as its live child does", async () => { const store = new AgentModelCatalogStore() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read and the catalog key touch only these fields. diff --git a/src/main/claude/claude-structured-in-turn-stop.test.ts b/src/main/claude/claude-structured-in-turn-stop.test.ts new file mode 100644 index 000000000000..04afb55bad63 --- /dev/null +++ b/src/main/claude/claude-structured-in-turn-stop.test.ts @@ -0,0 +1,184 @@ +// A user's Stop inside a live Claude chat interrupts the turn before the host ends its child. The +// turn's end then comes from the CLI's result frame, which CLIs before 2.1.91 send with no +// terminal_reason. + +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { ClaudeControlRequestError } from './claude-stream-json-connection' +import { + PROVIDER_SESSION_ID, + USER_MESSAGE, + adapterFor, + fakeClaude, + identityFor, + type FakeConnection +} from './claude-structured-session-test-support' + +const CUT_SHORT = { + type: 'result', + subtype: 'error_during_execution', + is_error: true, + session_id: PROVIDER_SESSION_ID, + parent_tool_use_id: null +} + +async function runningChat(claude: ReturnType): Promise<{ + adapter: ReturnType + bodies: Map + connection: FakeConnection + turnId: string +}> { + const bodies = new Map() + const adapter = adapterFor(claude) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: { + appendItem: (identity, body) => bodies.set(agentJournalItemKey(identity), body), + appendTombstone: (identity) => bodies.delete(agentJournalItemKey(identity)), + publish: vi.fn() + } + }) + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-a', + body: USER_MESSAGE, + fence: 7 + }) + const connection = claude.connections[0]! + // Claude adopts the client uuid for the echo that opens the turn. + connection.handlers.onMessage?.({ ...connection.sent.at(-1)! }) + const turnId = [...bodies.values()] + .map((body) => readAgentJournalTurn(body)) + .find((turn) => turn?.state === 'running')?.turnId + if (!turnId) { + throw new Error('expected a running turn') + } + return { adapter, bodies, connection, turnId } +} + +function settled(bodies: Map, turnId: string) { + return [...bodies.values()] + .map((body) => readAgentJournalTurn(body)) + .find((turn) => turn?.turnId === turnId && turn.state !== 'running') +} + +function providerRows(bodies: Map): string[] { + return [...bodies.keys()].filter((key) => key.includes('provider-frame')) +} + +describe("a user's Stop inside a live Claude chat", () => { + it('records the turn the interrupt cut as their cancellation when the CLI names no reason', async () => { + const claude = fakeClaude({ + routes: { + // The CLI aborts the turn, then acknowledges the interrupt. + interrupt: () => { + claude.connections[0]!.handlers.onMessage?.(CUT_SHORT) + return undefined + } + } + }) + const { adapter, bodies, turnId } = await runningChat(claude) + + await expect(adapter.cancelTurn({ sessionId: 'session-1', turnId, fence: 7 })).resolves.toEqual( + { cancelled: true } + ) + + expect(settled(bodies, turnId)).toMatchObject({ + state: 'interrupted', + outcome: 'cancellation' + }) + expect(providerRows(bodies)).toEqual([]) + }) + + // The chat's Stop button names no turn: it stops whatever the conversation has open. + it('records the open turn a Stop naming no turn cut as their cancellation', async () => { + const claude = fakeClaude({ + routes: { + interrupt: () => { + claude.connections[0]!.handlers.onMessage?.(CUT_SHORT) + return undefined + } + } + }) + const { adapter, bodies, turnId } = await runningChat(claude) + + await expect(adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ + cancelled: true + }) + + expect(settled(bodies, turnId)).toMatchObject({ + state: 'interrupted', + outcome: 'cancellation' + }) + expect(providerRows(bodies)).toEqual([]) + }) + + it('reads the same result with no Stop as a failure', async () => { + const { bodies, connection, turnId } = await runningChat(fakeClaude()) + + connection.handlers.onMessage?.(CUT_SHORT) + + expect(settled(bodies, turnId)).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(providerRows(bodies)).toHaveLength(1) + }) + + it('keeps a Stop naming no turn off the turn after it', async () => { + const claude = fakeClaude({ + routes: { + interrupt: () => { + claude.connections[0]!.handlers.onMessage?.(CUT_SHORT) + return undefined + } + } + }) + const { adapter, bodies, connection } = await runningChat(claude) + await adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) + + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-b', + body: USER_MESSAGE, + fence: 7 + }) + connection.handlers.onMessage?.({ ...connection.sent.at(-1)! }) + const nextTurnId = [...bodies.values()] + .map((body) => readAgentJournalTurn(body)) + .find((turn) => turn?.state === 'running')?.turnId + if (!nextTurnId) { + throw new Error('expected the next turn running') + } + connection.handlers.onMessage?.(CUT_SHORT) + + expect(settled(bodies, nextTurnId)).toMatchObject({ state: 'completed', outcome: 'failure' }) + }) + + // The Stop ends the child next, so the turn it was asked for reads Interrupted however it ends. + it.each([ + ['naming the turn', true], + ['naming no turn', false] + ] as const)( + 'reads a turn the CLI refused to interrupt as the user stopping it, %s', + async (_label, named) => { + const claude = fakeClaude({ + routes: { + interrupt: () => { + throw new ClaudeControlRequestError('interrupt', 'not running') + } + } + }) + const { adapter, bodies, connection, turnId } = await runningChat(claude) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', ...(named ? { turnId } : {}), fence: 7 }) + ).resolves.toEqual({ cancelled: false }) + connection.handlers.onMessage?.(CUT_SHORT) + + expect(settled(bodies, turnId)).toMatchObject({ outcome: 'cancellation' }) + expect(providerRows(bodies)).toHaveLength(0) + } + ) +}) diff --git a/src/main/claude/claude-structured-inbound-control.ts b/src/main/claude/claude-structured-inbound-control.ts index fc2810e8a75a..93e91bf6d181 100644 --- a/src/main/claude/claude-structured-inbound-control.ts +++ b/src/main/claude/claude-structured-inbound-control.ts @@ -10,7 +10,6 @@ export type ClaudePermissionCallbackDeps = { sessionId: string prompts: ClaudePromptRegistry emit: (event: ClaudeStructuredSessionEvent) => void - currentTurnId?: () => string | null } function denySafeResult(toolUseId: string | undefined): PermissionResult { @@ -52,8 +51,7 @@ export function buildClaudePermissionCallbacks(deps: ClaudePermissionCallbackDep toolUseId: options.toolUseID, input, suggestions: options.suggestions ?? [], - settle, - turnId: deps.currentTurnId?.() ?? null + settle }) if (!prompt) { settle(denySafeResult(options.toolUseID)) diff --git a/src/main/claude/claude-structured-journal-prompts.ts b/src/main/claude/claude-structured-journal-prompts.ts index 92301af106c0..caf6de4dea53 100644 --- a/src/main/claude/claude-structured-journal-prompts.ts +++ b/src/main/claude/claude-structured-journal-prompts.ts @@ -202,6 +202,18 @@ export class ClaudeJournalPrompts { this.deletePrompt(promptKey) } + /** The host records the card itself, so nothing here writes it any more. The returned undo hands + * it back when that record fails, so Claude's own withdrawal can still close it. */ + handOver(promptKey: string): () => void { + const entry = this.items.get(promptKey) + this.deletePrompt(promptKey) + return () => { + if (entry && !this.items.has(promptKey)) { + this.items.set(promptKey, { items: entry.items, cancellationPending: false }) + } + } + } + clear(): void { this.items.clear() this.pendingCancellationTotal = 0 diff --git a/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts b/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts index 312eb6d05a27..c8a9e014950c 100644 --- a/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts +++ b/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts @@ -519,7 +519,7 @@ describe('claude journal translation — background task rows', () => { const { translator, fallbackRows, taskRowIds, taskRowTexts } = harness() playFailedBackgroundCommand(translator) - // ABLATION: drop `message:system:task_*` from CLAUDE_TYPED_TRANSLATOR_KINDS + // ABLATION: drop `message:system:task_*` from TYPED_TRANSLATOR_KINDS // and this is `['claude · message:system:task_updated', 'claude · // message:system:task_notification']` — the reported bug exactly. expect(fallbackRows()).toEqual([]) diff --git a/src/main/claude/claude-structured-journal-translation-subagents.test.ts b/src/main/claude/claude-structured-journal-translation-subagents.test.ts index 89df86122b8d..e1da571eb348 100644 --- a/src/main/claude/claude-structured-journal-translation-subagents.test.ts +++ b/src/main/claude/claude-structured-journal-translation-subagents.test.ts @@ -471,18 +471,6 @@ describe('claude journal translation — which agent produced a row', () => { }) }) - it('stamps a spawn call a release never announces with the call\u2019s own id', () => { - // Older releases name nothing they spawn. The spawn call is still in the - // transcript, so its id is a real handle — and stamping it keeps the child's - // prose off the parent, which reading these rows as root would not. - const { translator, linkageOfProse } = harness() - translator.handle(userTurn('user-1')) - translator.handle(spawnCall('assistant-1', 'toolu_1')) - translator.handle(childProse('child-1', 'toolu_1', 'unannounced release')) - - expect(linkageOfProse('unannounced release')).toMatchObject({ agentId: 'toolu_1' }) - }) - it('never reads a row naming a parent as the session\u2019s own, whatever the release', () => { // The hardest case for attribution: a sidechain id no spawn call forwarded, // on a release that has announced nothing, so no announcement is coming and diff --git a/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts b/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts index ee0f4970f4db..35f3422c187e 100644 --- a/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts +++ b/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts @@ -256,6 +256,7 @@ describe('Claude structured turn timing', () => { { turnId: 'user-1', state: 'interrupted', + outcome: 'superseded', startedAt: 1_000, completedAt: 3_000, userItemId: USER_1_KEY diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index f49eed242d58..0d09485a9877 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -1,3 +1,7 @@ +import { + childEndCauseOfEndedEvent, + turnVerdictForChildEnd +} from '../native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict' import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' @@ -185,8 +189,11 @@ export function createClaudeJournalTranslator( streamedText.flush() subagents.settleSession() backgroundTasks.settleSession() - // The host saw the child end, so the turn's end is observed, not lost. - turn.settle({ state: 'interrupted', completedAt: event.observedAt ?? Date.now() }) + // The host saw the child end, so the turn's end is observed, not lost; its verdict is only + // what the host's own cause says, a user's stop of this chat or else news. + turn.settle( + turnVerdictForChildEnd(childEndCauseOfEndedEvent(event), event.observedAt ?? Date.now()) + ) // A frame that arrives after the child is gone must not open a turn no // event can close. turn.suppressReopen() @@ -275,6 +282,7 @@ export function createClaudeJournalTranslator( get currentTurnId() { return turn.id }, + recordTurnStop: (turnId, cause) => turn.recordStop(turnId, cause), get commandTurnId() { return turn.command ? turn.id : null }, diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index a92149a2cddf..e5b7a7ea8a77 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -133,15 +133,6 @@ describe('Claude structured Fast mode', () => { expect(applyFlagSettings).not.toHaveBeenCalled() }) - it('does not authorize a new Fast enable when model support is unknown', async () => { - const { session, applyFlagSettings } = fastModeSession(undefined) - - await expect( - setClaudeStructuredOption(session, { key: 'fastMode', value: 'true' }, undefined) - ).rejects.toThrow('does not support Fast mode') - expect(applyFlagSettings).not.toHaveBeenCalled() - }) - it.each([undefined, false])( 'allows explicit Fast off when model support is %s', async (supportsFastMode) => { diff --git a/src/main/claude/claude-structured-prompt-items.test.ts b/src/main/claude/claude-structured-prompt-items.test.ts index f032d4bcf8d2..56d55a7e236d 100644 --- a/src/main/claude/claude-structured-prompt-items.test.ts +++ b/src/main/claude/claude-structured-prompt-items.test.ts @@ -53,8 +53,7 @@ describe('Claude structured approval presentation', () => { options: [ { id: 'allow', label: 'Allow' }, { id: 'allowForSession', label: 'Allow for this session' }, - { id: 'deny', label: 'Deny' }, - { id: 'cancel', label: 'Stop' } + { id: 'deny', label: 'Deny' } ] }) }) @@ -75,12 +74,33 @@ describe('Claude structured approval presentation', () => { detail: '# Release\n\n- Run tests', options: [ { id: 'allow', label: 'Approve plan' }, - { id: 'deny', label: 'Keep planning' }, - { id: 'cancel', label: 'Stop' } + { id: 'deny', label: 'Keep planning' } ] }) }) + it('answers a dismissal as a plain deny, and a dismissed plan by asking Claude to wait', () => { + const tool = buildClaudePromptReply(approvalPrompt({ command: 'ls' }), { + kind: 'option', + optionId: 'cancel' + }) + const plan = buildClaudePromptReply( + approvalPrompt({ plan: '# Release' }, { subject: { kind: 'plan', text: '# Release' } }), + { kind: 'option', optionId: 'cancel' } + ) + + expect(tool).toEqual({ + behavior: 'deny', + message: 'User denied this action.', + toolUseID: expect.any(String) + }) + expect(plan).toMatchObject({ + behavior: 'deny', + message: expect.stringMatching(/wait for them/) + }) + expect(plan).not.toHaveProperty('interrupt') + }) + it('uses a plan-specific fallback title when the harness omits one', () => { const item = claudeApprovalItem( approvalPrompt({ plan: '# Release' }, { subject: { kind: 'plan', text: '# Release' } }) diff --git a/src/main/claude/claude-structured-prompt-items.ts b/src/main/claude/claude-structured-prompt-items.ts index d50735a76bf5..c74e4741fc1a 100644 --- a/src/main/claude/claude-structured-prompt-items.ts +++ b/src/main/claude/claude-structured-prompt-items.ts @@ -9,23 +9,19 @@ import { formatToolInput, truncateToolDetail } from '../../shared/native-chat-to import { boundJournalPromptBody } from '../native-chat/agent-session-journal/journal-prompt-body-bounds' import { claudeRecord, claudeText } from './claude-structured-item-translation' import { - CLAUDE_APPROVAL_DECISIONS, encodeClaudeQuestionOptionId, - type ClaudeApprovalDecision, type ClaudePendingPrompt } from './claude-structured-prompt-replies' -const APPROVAL_LABELS: Record = { - allow: 'Allow', - allowForSession: 'Allow for this session', - deny: 'Deny', - cancel: 'Stop' -} +const APPROVAL_OPTIONS: readonly AgentJournalPromptOption[] = [ + { id: 'allow', label: 'Allow' }, + { id: 'allowForSession', label: 'Allow for this session' }, + { id: 'deny', label: 'Deny' } +] const PLAN_APPROVAL_OPTIONS: readonly AgentJournalPromptOption[] = [ { id: 'allow', label: 'Approve plan' }, - { id: 'deny', label: 'Keep planning' }, - { id: 'cancel', label: 'Stop' } + { id: 'deny', label: 'Keep planning' } ] const PENDING = { @@ -60,12 +56,9 @@ export function claudeApprovalItem(prompt: ClaudePendingPrompt): AgentJournalApp ...(prompt.matchedAskRule ? { matchedAskRule: prompt.matchedAskRule } : {}), ...(prompt.subject ? { subject: prompt.subject } : {}), detail: detail || null, - options: planSubject - ? PLAN_APPROVAL_OPTIONS.map((option) => ({ ...option })) - : CLAUDE_APPROVAL_DECISIONS.map((decision) => ({ - id: decision, - label: APPROVAL_LABELS[decision] - })), + options: (planSubject ? PLAN_APPROVAL_OPTIONS : APPROVAL_OPTIONS).map((option) => ({ + ...option + })), resolution: { ...PENDING } }) } diff --git a/src/main/claude/claude-structured-prompt-ownership.test.ts b/src/main/claude/claude-structured-prompt-ownership.test.ts index 31fd703088f5..93f38f221ee9 100644 --- a/src/main/claude/claude-structured-prompt-ownership.test.ts +++ b/src/main/claude/claude-structured-prompt-ownership.test.ts @@ -2,18 +2,15 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-t import { describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' -import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import type { StructuredAgentSessionAppendOptions, StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { ClaudeControlRequestError } from './claude-stream-json-connection' import { ClaudeJournalPrompts } from './claude-structured-journal-prompts' import { claudeQuestionItems } from './claude-structured-prompt-items' import type { ClaudePendingPrompt } from './claude-structured-prompt-replies' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' import { - PROVIDER_SESSION_ID, USER_MESSAGE, acquired, adapterFor, @@ -131,16 +128,6 @@ describe('Claude live prompt ownership', () => { }) await vi.waitFor(() => expect(commitStarted).toHaveBeenCalledOnce()) - await expect( - adapter.cancelTurn({ - sessionId: 'session-1', - turnId: 'turn-1', - fence: 7, - prompt: { itemId: 'journal-prompt' } - }) - ).resolves.toEqual({ cancelled: false }) - expect(claude.connections[0]?.calls.some((call) => call.subtype === 'interrupt')).toBe(false) - commitGate.resolve() await answer await expect(answered.promise).resolves.toMatchObject({ @@ -149,196 +136,80 @@ describe('Claude live prompt ownership', () => { }) }) - it('lets prompt cancellation win and waits for SDK abort cleanup', async () => { - const interruptGate = deferred() - const controller = new AbortController() - const claude = fakeClaude({ - replayUuid: 'turn-1', - routes: { interrupt: () => interruptGate.promise } - }) - const adapter = await acquired(claude) - await startTurn(adapter) - const connection = claude.connections[0] - if (!connection) { - throw new Error('expected Claude connection') - } - const answered = invokeCanUseTool(connection, 'Bash', 'permission-1', 'tool-1', { - input: { command: 'git status' }, - signal: controller.signal - }) - adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-1') - - let cancellationSettled = false - const cancellation = adapter - .cancelTurn({ - sessionId: 'session-1', - turnId: 'turn-1', - fence: 7, - prompt: { itemId: 'journal-prompt' } - }) - .finally(() => { - cancellationSettled = true - }) - await vi.waitFor(() => expect(claude.connections[0]?.calls.at(-1)?.subtype).toBe('interrupt')) - const commit = vi.fn(async () => undefined) - await expect( - adapter.answerPrompt({ - sessionId: 'session-1', - itemId: 'journal-prompt', - kind: 'approval', - response: { kind: 'option', optionId: 'allow' }, - fence: 7, - commit - }) - ).rejects.toThrow(/no longer waiting/) - expect(commit).not.toHaveBeenCalled() - - interruptGate.resolve() - await Promise.resolve() - expect(cancellationSettled).toBe(false) - expect(answered.settled()).toBe(false) - controller.abort() - await expect(cancellation).resolves.toEqual({ cancelled: true }) - await expect(answered.promise).resolves.toBeNull() - await expect( - adapter.answerPrompt({ - sessionId: 'session-1', - itemId: 'journal-prompt', - kind: 'approval', - response: { kind: 'option', optionId: 'allow' }, - fence: 7, - commit - }) - ).rejects.toThrow(/no longer waiting/) - expect(controller.signal.aborted).toBe(true) - expect(commit).not.toHaveBeenCalled() - }) - - it('cancels an owned prompt after another dispatch queues behind its turn', async () => { - const controller = new AbortController() - let queuedUuid = '' - const claude = fakeClaude({ - replayUuids: ['turn-1', null], - capabilities: ['interrupt_cancel_queued_v1'], - routes: { - interrupt: () => { - controller.abort() - return { still_queued: [], cancelled: [queuedUuid] } - } - } + it("keeps the user's dismissal when Claude cancels the request while the host records it", async () => { + const claude = fakeClaude({ replayUuid: 'turn-1' }) + const recorded = lifecycleRecorder() + const adapter = adapterFor(claude) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: recorded.sink }) - const lateSettlements: unknown[] = [] - const adapter = await acquired(claude, {}, [], (settlement) => lateSettlements.push(settlement)) await startTurn(adapter) - const connection = claude.connections[0] - if (!connection) { - throw new Error('expected Claude connection') - } - const answered = invokeCanUseTool(connection, 'Bash', 'permission-queued', 'tool-queued', { - input: { command: 'git status' }, - signal: controller.signal + const request = new AbortController() + invokeCanUseTool(claude.connections[0]!, 'AskUserQuestion', 'permission-1', 'tool-1', { + input: { questions: [{ question: 'Which branch?', options: [{ label: 'main' }] }] }, + signal: request.signal }) - adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-queued') - await expect( - adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'queued-message', - body: USER_MESSAGE, - fence: 7 - }) - ).resolves.toEqual({ state: 'admitted' }) - const sentUuid = connection.sent.at(-1)?.uuid - if (typeof sentUuid !== 'string') { - throw new Error('expected queued dispatch uuid') + const [card] = [...recorded.bodies].find(([, body]) => body.kind === 'question') ?? [] + if (!card) { + throw new Error('expected the question card') } - queuedUuid = sentUuid - await expect( - adapter.cancelTurn({ - sessionId: 'session-1', - turnId: 'turn-1', - fence: 7, - prompt: { itemId: 'journal-prompt' } - }) - ).resolves.toEqual({ cancelled: true }) - await expect(answered.promise).resolves.toBeNull() - expect(connection.calls).toContainEqual({ - subtype: 'interrupt', - params: { cancelQueued: true } - }) - expect(lateSettlements).toContainEqual({ - sessionId: 'session-1', - clientMessageId: 'queued-message', - state: 'rejected', - reason: 'provider_cancelled_before_start', - rejection: { kind: 'cancelled' } - }) - }) - - it('does not interrupt a queued turn when the CLI cannot cancel queued messages', async () => { - const claude = fakeClaude({ replayUuids: ['turn-1', null] }) - const adapter = await acquired(claude) - await startTurn(adapter) - const connection = claude.connections[0] - if (!connection) { - throw new Error('expected Claude connection') + const dismiss = adapter.dismissPrompt + if (!dismiss) { + throw new Error('expected Claude to dismiss a card') } - const controller = new AbortController() - const answered = invokeCanUseTool(connection, 'Bash', 'permission-legacy', 'tool-legacy', { - input: { command: 'git status' }, - signal: controller.signal + await dismiss({ + sessionId: 'session-1', + itemId: card, + fence: 7, + answer: false, + // Claude's own cancel lands mid-commit, as an interrupt's can. + commit: async () => request.abort() }) - adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-legacy') - await expect( - adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'queued-message', - body: USER_MESSAGE, - fence: 7 - }) - ).resolves.toEqual({ state: 'admitted' }) - await expect( - adapter.cancelTurn({ - sessionId: 'session-1', - turnId: 'turn-1', - fence: 7, - prompt: { itemId: 'journal-prompt' } - }) - ).resolves.toEqual({ cancelled: false }) - expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(false) - controller.abort() - await expect(answered.promise).resolves.toBeNull() + expect(recorded.bodies.get(card)).toMatchObject({ resolution: { state: 'pending' } }) }) - it('does not interrupt a newer active turn through a stale prompt callback', async () => { - const claude = fakeClaude({ replayUuids: ['turn-1', 'turn-2'] }) - const adapter = await acquired(claude) + it('hands the card back when the host fails to record it, so a withdrawal still closes it', async () => { + const claude = fakeClaude({ replayUuid: 'turn-1' }) + const recorded = lifecycleRecorder() + const adapter = adapterFor(claude) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: recorded.sink + }) await startTurn(adapter) - const connection = claude.connections[0] - if (!connection) { - throw new Error('expected Claude connection') - } - const controller = new AbortController() - const answered = invokeCanUseTool(connection, 'Bash', 'permission-stale', 'tool-stale', { - input: { command: 'git status' }, - signal: controller.signal + const request = new AbortController() + invokeCanUseTool(claude.connections[0]!, 'AskUserQuestion', 'permission-1', 'tool-1', { + input: { questions: [{ question: 'Which branch?', options: [{ label: 'main' }] }] }, + signal: request.signal }) - adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-stale') - await startTurn(adapter, 'turn-2') + const [card] = [...recorded.bodies].find(([, body]) => body.kind === 'question') ?? [] + const dismiss = adapter.dismissPrompt + if (!card || !dismiss) { + throw new Error('expected the question card and a dismissal') + } await expect( - adapter.cancelTurn({ + dismiss({ sessionId: 'session-1', - turnId: 'turn-1', + itemId: card, fence: 7, - prompt: { itemId: 'journal-prompt' } + answer: false, + // Claude withdraws the request, then the host's record fails. + commit: async () => { + request.abort() + throw new Error('journal write failed') + } }) - ).resolves.toEqual({ cancelled: false }) - expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(false) - expect(answered.settled()).toBe(false) - controller.abort() - await expect(answered.promise).resolves.toBeNull() + ).rejects.toThrow('journal write failed') + + expect(recorded.bodies.get(card)).toMatchObject({ resolution: { state: 'cancelled' } }) }) it('drops resolved prompt bodies instead of retaining them for the session lifetime', () => { @@ -370,141 +241,6 @@ describe('Claude live prompt ownership', () => { expect(prompts.size).toBe(0) }) - it('releases the callback claim after a failed interrupt', async () => { - const claude = fakeClaude({ - replayUuid: 'turn-1', - routes: { - interrupt: () => { - throw new ClaudeControlRequestError('interrupt', 'not running') - } - } - }) - const adapter = await acquired(claude) - await startTurn(adapter) - const connection = claude.connections[0] - if (!connection) { - throw new Error('expected Claude connection') - } - const answered = invokeCanUseTool(connection, 'Bash', 'permission-1', 'tool-1', { - input: { command: 'git status' } - }) - adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-1') - - await expect( - adapter.cancelTurn({ - sessionId: 'session-1', - turnId: 'turn-1', - fence: 7, - prompt: { itemId: 'journal-prompt' } - }) - ).resolves.toEqual({ cancelled: false }) - await adapter.answerPrompt({ - sessionId: 'session-1', - itemId: 'journal-prompt', - kind: 'approval', - response: { kind: 'option', optionId: 'allow' }, - fence: 7, - commit: async () => undefined - }) - await expect(answered.promise).resolves.toMatchObject({ - behavior: 'allow', - toolUseID: 'tool-1' - }) - }) - - it('enqueues terminal prompt state before a confirmed cancellation resolves', async () => { - const controller = new AbortController() - const claude = fakeClaude({ - replayUuid: 'turn-1', - routes: { interrupt: () => controller.abort() } - }) - const recorded = lifecycleRecorder() - const adapter = adapterFor(claude) - await adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - events: recorded.sink - }) - await startTurn(adapter) - const connection = claude.connections[0] - if (!connection) { - throw new Error('expected Claude connection') - } - const answered = invokeCanUseTool(connection, 'Bash', 'permission-1', 'tool-1', { - input: { command: 'git status' }, - signal: controller.signal - }) - adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-1') - const promptItemId = [...recorded.bodies].find(([, body]) => body.kind === 'approval')?.[0] - - const cancellation = adapter - .cancelTurn({ - sessionId: 'session-1', - turnId: 'turn-1', - fence: 7, - prompt: { itemId: 'journal-prompt' } - }) - .then((result) => { - recorded.order.push('resolved') - return result - }) - - await expect(cancellation).resolves.toEqual({ cancelled: true }) - await expect(answered.promise).resolves.toBeNull() - if (!promptItemId) { - throw new Error('expected a recorded prompt item') - } - expect(recorded.order).toEqual(['prompt-lifecycle', 'resolved']) - expect( - [...recorded.bodies.values()].some( - (body) => - (body.kind === 'approval' || body.kind === 'question') && - body.resolution.state === 'pending' - ) - ).toBe(false) - expect(recorded.bodies.get(promptItemId)).toMatchObject({ - resolution: { state: 'cancelled' } - }) - expect( - [...recorded.bodies.values()].some( - (body) => readAgentJournalTurn(body)?.state === 'interrupted' - ) - ).toBe(false) - - connection.handlers.onMessage?.({ - type: 'result', - subtype: 'error_during_execution', - uuid: 'result-1', - session_id: PROVIDER_SESSION_ID, - is_error: true, - terminal_reason: 'aborted_tools', - errors: [], - duration_ms: 654 - }) - expect([...recorded.bodies.values()].find((body) => readAgentJournalTurn(body))).toMatchObject({ - state: 'interrupted', - durationMs: 654 - }) - - connection.handlers.onMessage?.({ - type: 'result', - subtype: 'success', - uuid: 'result-duplicate', - session_id: PROVIDER_SESSION_ID, - is_error: false, - terminal_reason: 'completed', - duration_ms: 999 - }) - expect([...recorded.bodies.values()].find((body) => readAgentJournalTurn(body))).toMatchObject({ - state: 'interrupted', - durationMs: 654 - }) - - expect(controller.signal.aborted).toBe(true) - expect(recorded.tombstones).toHaveLength(0) - }) - it('does not synthesize terminal lifecycle for ordinary Stop', async () => { const events: ClaudeStructuredSessionEvent[] = [] const adapter = await acquired(fakeClaude({ replayUuid: 'turn-1' }), {}, events) @@ -519,110 +255,6 @@ describe('Claude live prompt ownership', () => { ).toBe(false) }) - it('does not report success or release the claim when prompt lifecycle admission fails', async () => { - const controller = new AbortController() - const claude = fakeClaude({ - replayUuid: 'turn-1', - routes: { interrupt: () => controller.abort() } - }) - const recorded = lifecycleRecorder(false) - const adapter = adapterFor(claude) - await adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - events: recorded.sink - }) - await startTurn(adapter) - const connection = claude.connections[0] - if (!connection) { - throw new Error('expected Claude connection') - } - invokeCanUseTool(connection, 'Bash', 'permission-1', 'tool-1', { - input: { command: 'git status' }, - signal: controller.signal - }) - const promptItemId = [...recorded.bodies].find(([, body]) => body.kind === 'approval')?.[0] - if (!promptItemId) { - throw new Error('expected durable Claude prompt') - } - - await expect( - adapter.cancelTurn({ - sessionId: 'session-1', - turnId: 'turn-1', - fence: 7, - prompt: { itemId: promptItemId } - }) - ).rejects.toThrow(/lifecycle was not admitted/) - const commit = vi.fn(async () => undefined) - await expect( - adapter.answerPrompt({ - sessionId: 'session-1', - itemId: promptItemId, - kind: 'approval', - response: { kind: 'option', optionId: 'allow' }, - fence: 7, - commit - }) - ).rejects.toThrow(/no longer waiting/) - expect(commit).not.toHaveBeenCalled() - }) - - it('checks the bound item, turn, fence, and current acquisition without callback revival', async () => { - const claude = fakeClaude({ replayUuid: 'turn-1' }) - const adapter = await acquired(claude) - await startTurn(adapter) - const connection = claude.connections[0] - if (!connection) { - throw new Error('expected Claude connection') - } - const answered = invokeCanUseTool(connection, 'Bash', 'permission-1', 'tool-1', { - input: { command: 'git status' } - }) - adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-1') - - for (const input of [ - { turnId: 'turn-1', fence: 7, itemId: 'other-item' }, - { turnId: 'turn-2', fence: 7, itemId: 'journal-prompt' }, - { turnId: 'turn-1', fence: 6, itemId: 'journal-prompt' } - ]) { - await expect( - adapter.cancelTurn({ - sessionId: 'session-1', - turnId: input.turnId, - fence: input.fence, - prompt: { itemId: input.itemId } - }) - ).resolves.toEqual({ cancelled: false }) - } - expect(claude.connections[0]?.calls.some((call) => call.subtype === 'interrupt')).toBe(false) - - await adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) - await expect(answered.promise).resolves.toBeNull() - await expect( - adapter.cancelTurn({ - sessionId: 'session-1', - turnId: 'turn-1', - fence: 8, - prompt: { itemId: 'journal-prompt' } - }) - ).resolves.toEqual({ cancelled: false }) - const commit = vi.fn(async () => undefined) - await expect( - adapter.answerPrompt({ - sessionId: 'session-1', - itemId: 'journal-prompt', - kind: 'approval', - response: { kind: 'option', optionId: 'allow' }, - fence: 8, - commit - }) - ).rejects.toThrow(/no longer waiting/) - expect(commit).not.toHaveBeenCalled() - expect(claude.connections[1]?.calls.some((call) => call.subtype === 'interrupt')).toBe(false) - }) - it('rejects a grouped prompt batch without partially revising its first row', () => { const tombstones: string[] = [] const appendTombstone = vi.fn( diff --git a/src/main/claude/claude-structured-prompt-ownership.ts b/src/main/claude/claude-structured-prompt-ownership.ts index 78c63af80752..48b462ed1746 100644 --- a/src/main/claude/claude-structured-prompt-ownership.ts +++ b/src/main/claude/claude-structured-prompt-ownership.ts @@ -3,45 +3,18 @@ import { AgentSessionPromptUnavailableError, type StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' -import { - answerClaudePrompt, - cancelClaudeTurn, - supportsClaudeQueuedInterruptCancellation -} from './claude-structured-control-actions' +import type { StructuredAgentSessionAdapterStop } from '../native-chat/agent-session-wire/structured-agent-session-adapter-stop' +import { answerClaudePrompt, cancelClaudeTurn } from './claude-structured-control-actions' import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution' -import { buildClaudePromptReply } from './claude-structured-prompt-replies' +import { buildClaudePromptReply, claudePromptDismissal } from './claude-structured-prompt-replies' import type { ClaudeSession } from './claude-structured-session-state' import type { ClaudePendingPrompt } from './claude-prompt-registry' +import { CLAUDE_STOP_GRACE_MS } from './claude-request-end-wait' import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk' type CancelInput = Parameters[0] type AnswerInput = Parameters[0] -export function admitClaudePromptCancellation(session: ClaudeSession, promptKey: string): boolean { - const admission = session.translator?.journalPrompts.cancel(promptKey) - return admission?.accepted ?? true -} - -function waitForClaudePromptCancellation( - observed: Promise, - timeoutMs = CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS -): Promise { - let timer: ReturnType | null = null - const deadline = new Promise((_resolve, reject) => { - timer = setTimeout( - () => reject(new Error('Claude prompt cancellation abort was not observed')), - timeoutMs - ) - timer.unref?.() - }) - return Promise.race([observed, deadline]).finally(() => { - if (timer) { - clearTimeout(timer) - } - }) -} - function requireSession(sessions: Map, sessionId: string): ClaudeSession { const session = sessions.get(sessionId) if (!session) { @@ -75,22 +48,32 @@ function cancelClaudeConversation( session.fence === request.fence && session.acquisitionGeneration === acquisitionGeneration && (claudeLiveTurnId(session, request) !== null || session.dispatchWaiters.length > 0) - return cancelClaudeTurn(session, timeoutMs, isCurrent, onDispatchSettledLate) + // A turn is cancelled only at a client's request, so the stop is the user's: on the named turn, + // else on whatever turn is open. + const stoppedTurnId = request.turnId ?? session.translator?.currentTurnId ?? null + return cancelClaudeTurn( + session, + timeoutMs, + isCurrent, + onDispatchSettledLate, + stoppedTurnId === null ? undefined : { turnId: stoppedTurnId, cause: 'user-stop' } + ) } +/** A Stop's interrupt. A card's own Cancel never comes here: `claudePromptCancelRoute` routes it. */ export async function cancelClaudeStructuredTurn(input: { request: CancelInput sessions: Map timeoutMs?: number - admitPromptCancellation: (session: ClaudeSession, promptKey: string) => boolean onDispatchSettledLate?: ClaudeLateDispatchSettlement }): Promise<{ cancelled: boolean }> { - const { request, sessions, timeoutMs } = input + const { request, sessions } = input + // A Stop ends the child next, so its interrupt shares the grace with Claude's wind-down after it. + const timeoutMs = Math.min(input.timeoutMs ?? CLAUDE_STOP_GRACE_MS, CLAUDE_STOP_GRACE_MS) const session = requireSession(sessions, request.sessionId) const acquisitionGeneration = session.acquisitionGeneration - const prompt = request.prompt // Before startup lands nothing was written, so there is nothing to interrupt. - if (!prompt && session.startup.state === 'pending') { + if (request.prompt || session.startup.state === 'pending') { return { cancelled: false } } const requestedTurnId = request.turnId @@ -99,11 +82,10 @@ export async function cancelClaudeStructuredTurn(input: { // pending handover is no reason to hold it; the queue sweep settles that follow-up. With nothing // live, a written follow-up whose turn has not opened is one the naming client has not seen start. if ( - !prompt && - (requestedTurnId === undefined || - (session.translator?.commandTurnId !== requestedTurnId && - (liveTurnId === requestedTurnId || - (liveTurnId === null && session.dispatchWaiters.length > 0)))) + requestedTurnId === undefined || + (session.translator?.commandTurnId !== requestedTurnId && + (liveTurnId === requestedTurnId || + (liveTurnId === null && session.dispatchWaiters.length > 0))) ) { return cancelClaudeConversation( session, @@ -113,21 +95,6 @@ export async function cancelClaudeStructuredTurn(input: { input.onDispatchSettledLate ) } - if (requestedTurnId === undefined) { - return { cancelled: false } - } - if (prompt && session.fence !== request.fence) { - return { cancelled: false } - } - const claim = prompt ? session.prompts.claimBound(prompt.itemId, requestedTurnId) : null - if (prompt && !claim) { - return { cancelled: false } - } - const cancellationObserved = claim ? session.prompts.observeCancellation(claim) : null - if (claim && !cancellationObserved) { - session.prompts.releaseClaim(claim) - return { cancelled: false } - } // Judge against the published journal, because that is the only turn a client could have been // shown — but only while it HAS an answer. The journal drains through a serialized async queue, // so a null read means the row has not landed yet, not that nothing is running; falling back to @@ -148,53 +115,27 @@ export async function cancelClaudeStructuredTurn(input: { ![...session.dispatchWaiters, ...session.retiredDispatchWaiters].some( (waiter) => waiter.dispatchSequence === session.dispatchSequence ) - // Prompt cancellation has a separate callback-settlement contract, so only a provider with - // cancelQueued can release its uncertain queued send. - const dispatchAdmissionAllowsCancellation = (): boolean => - dispatchAdmissionIsCurrent() || - (Boolean(prompt) && supportsClaudeQueuedInterruptCancellation(session)) const compactionOwnsTurn = (): boolean => session.translator !== null && session.translator.commandTurnId === requestedTurnId - const isCurrent = (): boolean => - sessions.get(request.sessionId) === session && - session.fence === request.fence && - session.acquisitionGeneration === acquisitionGeneration && - (claim && prompt - ? ownsRequestedTurn() && - session.prompts.ownsBoundClaim(claim, prompt.itemId, requestedTurnId) && - dispatchAdmissionAllowsCancellation() - : compactionOwnsTurn() || (ownsRequestedTurn() && dispatchAdmissionAllowsCancellation())) - let interruptConfirmed = false - try { - const result = await cancelClaudeTurn( - session, - timeoutMs, - () => { - const current = isCurrent() - // Read with the result that ends it: a stopped command reports no compaction. - if (current && compactionOwnsTurn()) { - session.translator?.commandInterruptRequested(requestedTurnId) - } - return current - }, - input.onDispatchSettledLate - ) - if (result.cancelled && claim && cancellationObserved) { - interruptConfirmed = true - await waitForClaudePromptCancellation(cancellationObserved, timeoutMs) - if (!input.admitPromptCancellation(session, claim.found.prompt.promptKey)) { - throw new Error(`Claude prompt cancellation lifecycle was not admitted for ${claim.itemId}`) + // A turn is cancelled only at a client's request, so the stop is the user's. + return cancelClaudeTurn( + session, + timeoutMs, + () => { + const current = + sessions.get(request.sessionId) === session && + session.fence === request.fence && + session.acquisitionGeneration === acquisitionGeneration && + (compactionOwnsTurn() || (ownsRequestedTurn() && dispatchAdmissionIsCurrent())) + // Read with the result that ends it: a stopped command reports no compaction. + if (current && compactionOwnsTurn()) { + session.translator?.commandInterruptRequested(requestedTurnId) } - } else if (claim) { - session.prompts.releaseClaim(claim) - } - return result - } catch (error) { - if (claim && !interruptConfirmed) { - session.prompts.releaseClaim(claim) - } - throw error - } + return current + }, + input.onDispatchSettledLate, + { turnId: requestedTurnId, cause: 'user-stop' } + ) } function prepareClaudePromptReply( @@ -241,3 +182,41 @@ export async function answerClaudeStructuredPrompt(input: { throw error } } + +/** The host records the dismissal (`commit`) while the claim is held. A Stop that ends the child + * leaves the request to end with it; only `answer` declines it, so Claude never gets a reply racing + * that Stop's interrupt. */ +export async function dismissClaudeStructuredPrompt(input: { + request: Parameters>[0] + sessions: Map +}): Promise { + const { request, sessions } = input + const session = sessions.get(request.sessionId) + const claim = session?.fence === request.fence ? session.prompts.claim(request.itemId) : null + if (!session || !claim) { + throw new AgentSessionPromptUnavailableError(request.itemId) + } + const { promptKey } = claim.found.prompt + const journalPrompts = session.translator?.journalPrompts + // Before the commit: Claude's own cancel of the request, landing while the host writes, must + // not write after it. + const handBack = journalPrompts?.handOver(promptKey) + try { + try { + await request.commit() + } catch (error) { + // Nothing recorded the card: it is Claude's again, and a withdrawal Claude made meanwhile, + // which wrote nothing then, closes it now. + handBack?.() + if (!session.prompts.find(request.itemId)) { + journalPrompts?.cancel(promptKey) + } + throw error + } + if (request.answer && session.prompts.ownsClaim(claim)) { + await answerClaudePrompt(session, claim, claudePromptDismissal(claim.found.prompt)) + } + } finally { + session.prompts.releaseClaim(claim) + } +} diff --git a/src/main/claude/claude-structured-prompt-replies.ts b/src/main/claude/claude-structured-prompt-replies.ts index 3bb99e3638a3..a6007e4afdf4 100644 --- a/src/main/claude/claude-structured-prompt-replies.ts +++ b/src/main/claude/claude-structured-prompt-replies.ts @@ -3,6 +3,7 @@ import type { AgentSessionPromptResponse, AgentSessionQuestionAnswer } from '../../shared/agent-session-question-answer' +import type { StructuredAgentSessionAdapterStop } from '../native-chat/agent-session-wire/structured-agent-session-adapter-stop' import { claudePromptQuestions, isClaudePromptRecord, @@ -18,9 +19,32 @@ export { type ClaudePromptSettle } from './claude-prompt-registry' +/** No card offers `cancel` any more; an older card's "Stop" option answers it as a dismissal. */ export const CLAUDE_APPROVAL_DECISIONS = ['allow', 'allowForSession', 'deny', 'cancel'] as const export type ClaudeApprovalDecision = (typeof CLAUDE_APPROVAL_DECISIONS)[number] +/** A card's own Cancel: an approval is dismissed (a tool's with the reply its Deny sends, a plan's + * asking Claude to wait for the user), and a question ends the way the chat's Stop does. Nothing + * on a card interrupts the turn and leaves the child running. */ +export const claudePromptCancelRoute: NonNullable< + StructuredAgentSessionAdapterStop['routePromptCancel'] +> = ({ prompt }) => (prompt.kind === 'question' ? { kind: 'stop' } : { kind: 'dismiss' }) + +/** Declines a request the user dismissed. A dismissed plan or question waits on the user: Claude is + * told to end its turn rather than revise or ask again. */ +export function claudePromptDismissal(prompt: ClaudePendingPrompt): PermissionResult { + return { + behavior: 'deny', + message: + prompt.kind === 'question' + ? 'The user dismissed these questions without answering. End your turn and wait for them.' + : prompt.subject?.kind === 'plan' + ? 'The user dismissed this plan without approving it. End your turn and wait for them to say what to change.' + : 'User denied this action.', + toolUseID: prompt.toolUseId + } +} + function isClaudeApprovalDecision(optionId: string): optionId is ClaudeApprovalDecision { return CLAUDE_APPROVAL_DECISIONS.some((decision) => decision === optionId) } @@ -93,15 +117,15 @@ function approvalResponse(prompt: ClaudePendingPrompt, optionId: string): Permis toolUseID: prompt.toolUseId } } + if (decision === 'cancel') { + return claudePromptDismissal(prompt) + } return { behavior: 'deny', message: - decision === 'cancel' - ? 'User stopped this turn.' - : prompt.subject?.kind === 'plan' - ? 'The user asked you to keep planning. Revise the plan and call ExitPlanMode again.' - : 'User denied this action.', - ...(decision === 'cancel' ? { interrupt: true } : {}), + prompt.subject?.kind === 'plan' + ? 'The user asked you to keep planning. Revise the plan and call ExitPlanMode again.' + : 'User denied this action.', toolUseID: prompt.toolUseId } } diff --git a/src/main/claude/claude-structured-provider-fallback.ts b/src/main/claude/claude-structured-provider-fallback.ts index cd5441d7ec13..0da42846fe54 100644 --- a/src/main/claude/claude-structured-provider-fallback.ts +++ b/src/main/claude/claude-structured-provider-fallback.ts @@ -16,6 +16,7 @@ import { type ClaudeMessageEnvelope } from './claude-structured-item-translation' import { claudeResultOutcome } from './claude-result-outcome' +import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeRowStamp } from './claude-provisional-row-corrections' import { CLAUDE_API_RETRY_FRAME_KIND, @@ -51,11 +52,12 @@ export function isSettledClaudeResultKind(kind: string): boolean { * would only be noise. */ export function claudeResultFailure( - message: Record + message: Record, + stop: StructuredAgentSessionStopCause | null = null ): { text: string | null } | null { // A cancellation is not a fault and earns no error row; the outcome classifier // owns that distinction so this reader cannot drift from the turn's verdict. - if (claudeResultOutcome(message) !== 'failure') { + if (claudeResultOutcome(message, stop) !== 'failure') { return null } const result = claudeText(message.result)?.trim() diff --git a/src/main/claude/claude-structured-resume-point.ts b/src/main/claude/claude-structured-resume-point.ts index e8124f52d27d..b15d2e3507c9 100644 --- a/src/main/claude/claude-structured-resume-point.ts +++ b/src/main/claude/claude-structured-resume-point.ts @@ -2,6 +2,7 @@ import type { ClaudeSession, ClaudeStructuredSessionAdapterDeps } from './claude-structured-session-state' +import { settleClaudeTurnEndWaiters } from './claude-request-end-wait' /** * Record a completed turn: its leaf becomes the one close and exit persist, and the durable point @@ -17,6 +18,8 @@ export function persistClaudeTurnResumePoint( return } session.turnEndLeafUuid = session.leafUuid + // The turn ended: a Stop waiting to end the child re-reads what Claude still has in flight. + settleClaudeTurnEndWaiters(session) const leafUuid = session.turnEndLeafUuid const persist = deps.persistResumePoint if (!persist || leafUuid === null || session.resumePointWrite?.leafUuid === leafUuid) { diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index b4adbfccfa5c..92f84f6c4896 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -9,6 +9,7 @@ import { openClaudeStreamJsonConnection } from './claude-stream-json-connection' import { buildClaudePermissionCallbacks } from './claude-structured-inbound-control' import { resolveClaudeReplayTurn } from './claude-replay-turn-resolution' import { claudeSessionStateEndsTurn } from './claude-session-state-turn-over' +import { settleClaudeTurnEndWaiters } from './claude-request-end-wait' import { readClaudeCapabilities, readClaudeFrameString, @@ -118,8 +119,9 @@ export async function acquireClaudeSession({ if (message.type === 'result' && sessions.get(sessionId) === liveSession) { persistClaudeTurnResumePoint(sessionId, liveSession, deps) } - // The CLI idles only once its queue drains, so it holds none of this session's sends. + // The CLI's idle releases its doubted sends; a late echo still accepts one it goes on to run. if (claudeSessionStateEndsTurn(message) && sessions.get(sessionId) === liveSession) { + settleClaudeTurnEndWaiters(liveSession) deps.onSessionIdle?.({ sessionId }) } } @@ -153,7 +155,6 @@ export async function acquireClaudeSession({ const { canUseTool, onUserDialog } = buildClaudePermissionCallbacks({ sessionId, prompts, - currentTurnId: () => translator?.currentTurnId ?? null, emit: (event) => callbacks.deliver(attempt, sessionId, () => callbacks.emit(liveSession, input.events, event)) }) diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 64d3c67c2277..46e1d9936358 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -3,10 +3,12 @@ import { dispatchClaudeCommand } from './claude-structured-command-dispatch' import type { AgentSessionAcquisition, StructuredAgentSessionAcquireInput, - StructuredAgentSessionAdapter + StructuredAgentSessionAdapter, + StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { stopClaudeBackgroundTasks } from './claude-structured-control-actions' import { dispatchClaudeTurn } from './claude-structured-dispatch' +import { claudeHoldsDispatch } from './claude-command-lifecycle' import { releaseClaudeAcquisition } from './claude-structured-acquisition-release' import { acquireClaudeSession } from './claude-structured-session-acquisition' import { supportsClaudeStructuredLocation } from './claude-structured-location-support' @@ -26,6 +28,7 @@ import { type ClaudeStructuredSessionEvent } from './claude-structured-session-state' import { closeAllClaudeSessions, closeClaudeSession } from './claude-structured-session-close' +import { claudeStoppedRequestEndWait } from './claude-request-end-wait' import { drainClaudeObservedExits, observeClaudeSessionExit, @@ -36,10 +39,11 @@ import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session import { resolveClaudeProviderHistoryWindow } from './claude-structured-history-window' import { drainClaudeChildWork } from './claude-child-work-evidence' import { - admitClaudePromptCancellation, answerClaudeStructuredPrompt, - cancelClaudeStructuredTurn + cancelClaudeStructuredTurn, + dismissClaudeStructuredPrompt } from './claude-structured-prompt-ownership' +import { claudePromptCancelRoute } from './claude-structured-prompt-replies' export type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' export type { @@ -61,6 +65,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda private readonly exitLifecycle: ClaudeExitLifecycle constructor(private readonly deps: ClaudeStructuredSessionAdapterDeps) { + this.atRestCommands = deps.atRestCommands this.exitLifecycle = { sessions: this.sessions, exits: this.exits, @@ -140,33 +145,25 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda }) private emit(session: ClaudeSession | null, event: ClaudeStructuredSessionEvent): void { + // The host's child records, fed by the decoder's evidence drained below, are what every surface + // and every Stop reads; the tracker's roster is kept only for tests that compare the two. if (event.type === 'ended') { session?.childWork.clear() + session?.backgroundTasks.clear() } else if (event.type === 'message') { session?.childWork.observe(event.message) + session?.backgroundTasks.observe(event.message, event.startsTurn === true) } - const backgroundTasksChanged = - event.type === 'ended' - ? (session?.backgroundTasks.clear() ?? false) - : event.type === 'message' - ? (session?.backgroundTasks.observe(event.message, event.startsTurn === true) ?? false) - : false if (event.type === 'message' && session?.commands.observe(event.message)) { session.events?.publish() } session?.translator?.handle(event) this.deps.onEvent?.(event) - if (backgroundTasksChanged) { - this.deps.onBackgroundTasksChanged?.( - event.sessionId, - session ? backgroundTaskState(session) : null - ) - } this.publishChildWork(event.sessionId, session, event.type === 'message' ? event.message : null) } - /** After the journal handled the frame and the parent's own row was republished: the host - * never holds a child record ahead of the rows that frame wrote, and never before its parent. */ + /** After the journal handled the frame, which republished the parent's own row: the host never + * holds a child record ahead of the rows that frame wrote, and never before its parent. */ private publishChildWork( sessionId: string, session: ClaudeSession | null | undefined, @@ -179,12 +176,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda } bindPromptItemId(sessionId: string, journalItemId: string, promptKey: string): void { - const session = this.sessions.get(sessionId) - session?.prompts.bindJournalItemId( - journalItemId, - promptKey, - session.translator?.currentTurnId ?? null - ) + this.sessions.get(sessionId)?.prompts.bindJournalItemId(journalItemId, promptKey) } dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => @@ -197,36 +189,57 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda cancelClaudeStructuredTurn({ request, sessions: this.sessions, - admitPromptCancellation: (session, promptKey) => - admitClaudePromptCancellation(session, promptKey), onDispatchSettledLate: (settlement) => this.deps.onDispatchSettledLate?.({ sessionId: request.sessionId, ...settlement }), ...(this.deps.requestTimeoutMs === undefined ? {} : { timeoutMs: this.deps.requestTimeoutMs }) }) - stopBackgroundTasks: StructuredAgentSessionAdapter['stopBackgroundTasks'] = (input) => { + // Stop is a session boundary for Claude: an interrupt can answer while background work keeps the + // CLI running, and a refused one leaves the turn running. + stopEndsSession = (): boolean => true + awaitStoppedRequestEnd = claudeStoppedRequestEndWait(this.sessions) + routePromptCancel = claudePromptCancelRoute + dismissPrompt: StructuredAgentSessionAdapter['dismissPrompt'] = (request) => + dismissClaudeStructuredPrompt({ request, sessions: this.sessions }) + stopBackgroundTasks: NonNullable = async ( + input + ) => { const session = this.session(input.sessionId) const acquisitionGeneration = session.acquisitionGeneration - return stopClaudeBackgroundTasks( - session, - this.deps.requestTimeoutMs, - () => - Boolean( - this.sessions.get(input.sessionId) === session && - session.fence === input.fence && - session.acquisitionGeneration === acquisitionGeneration && - session.backgroundTasks.state - ), - input.taskId - ) + const isCurrent = () => + this.sessions.get(input.sessionId) === session && + session.fence === input.fence && + session.acquisitionGeneration === acquisitionGeneration + try { + return await stopClaudeBackgroundTasks( + session, + this.deps.requestTimeoutMs, + isCurrent, + input.taskIds + ) + } finally { + if (isCurrent()) { + this.publishChildWork(input.sessionId, session) + } + } } - backgroundTaskState: NonNullable = ( - sessionId - ) => { + /** The tracker's own roster, for the tests that compare it with the host's child records. No + * production code reads it: what runs, what a Stop reaches and what blocks a command are all + * read from the host's child records. */ + backgroundTaskState = (sessionId: string): AgentSessionBackgroundTaskState | null | undefined => { const session = this.sessions.get(sessionId) return session ? backgroundTaskState(session) : undefined } + backgroundTaskStops: NonNullable = ( + sessionId + ) => + this.sessions.has(sessionId) ? { supportsTaskStop: true, supportsStopAll: true } : undefined readCommands: NonNullable = (sessionId) => this.sessions.get(sessionId)?.commands.commands + readonly atRestCommands: ClaudeStructuredSessionAdapterDeps['atRestCommands'] + holdsDispatch = (sessionId: string): boolean => { + const session = this.sessions.get(sessionId) + return session ? claudeHoldsDispatch(session) : false + } answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (request) => answerClaudeStructuredPrompt({ request, sessions: this.sessions }) setOption: StructuredAgentSessionAdapter['setOption'] = (input) => @@ -271,32 +284,36 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda onExitProven: (sessionId, exit) => settleClaudeUnexpectedExit(this.exitLifecycle, sessionId, exit), ...(this.deps.persistHandle ? { persistHandle: this.deps.persistHandle } : {}), - ...(this.deps.onBackgroundTasksChanged - ? { onBackgroundTasksChanged: this.deps.onBackgroundTasksChanged } - : {}), ...(this.deps.onEvent ? { onEvent: this.deps.onEvent } : {}) }) - closeSession = (sessionId: string): Promise => + closeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => // After the close, not before: releasing an exit still settling settles it on the way. - this.closeSessionProcess(sessionId).finally(() => this.settledExitErrors.delete(sessionId)) + this.closeSessionProcess(sessionId, cause).finally(() => + this.settledExitErrors.delete(sessionId) + ) - private closeSessionProcess(sessionId: string): Promise { + private closeSessionProcess( + sessionId: string, + cause: StructuredAgentSessionStopCause | undefined + ): Promise { + // An exit seen first settles as that exit, whoever asked for the close after it. if (this.exits.has(sessionId)) { return this.releaseAcquisition({ sessionId }) } - return this.afterClose(sessionId, () => this.closeProviderSession(sessionId)) + return this.afterClose(sessionId, () => this.closeProviderSession(sessionId, cause)) } - private closeProviderSession = (sessionId: string): Promise => + private closeProviderSession = ( + sessionId: string, + stopCause?: StructuredAgentSessionStopCause + ): Promise => closeClaudeSession({ sessionId, + ...(stopCause ? { stopCause } : {}), sessions: this.sessions, acquisitions: this.acquisitions, ...(this.deps.persistHandle ? { persistHandle: this.deps.persistHandle } : {}), - ...(this.deps.onBackgroundTasksChanged - ? { onBackgroundTasksChanged: this.deps.onBackgroundTasksChanged } - : {}), ...(this.deps.onEvent ? { onEvent: this.deps.onEvent } : {}) }) diff --git a/src/main/claude/claude-structured-session-close.test.ts b/src/main/claude/claude-structured-session-close.test.ts index 873b90c94168..8881c6ae74b8 100644 --- a/src/main/claude/claude-structured-session-close.test.ts +++ b/src/main/claude/claude-structured-session-close.test.ts @@ -10,7 +10,7 @@ import { fakeClaude, identityFor } from './claude-structured-session-test-support' -import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import { AgentSessionAcquisitionRootExitObservedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { ClaudePromptRegistry } from './claude-structured-prompt-replies' import { closeClaudeSession } from './claude-structured-session-close' @@ -88,7 +88,7 @@ describe('Claude published session close lifecycle', () => { .fn>() .mockRejectedValueOnce(persistenceError) .mockResolvedValueOnce(undefined) - const backgroundStates: (AgentSessionBackgroundTaskState | null)[] = [] + const childWork: AgentChildWorkEvidence['type'][] = [] const adapter = adapterFor( claude, {}, @@ -96,7 +96,7 @@ describe('Claude published session close lifecycle', () => { [], undefined, persistHandle, - (_sessionId, state) => backgroundStates.push(state) + (_sessionId, evidence) => childWork.push(...evidence.map((edge) => edge.type)) ) const journalSink: StructuredAgentSessionEventSink = { appendItem: () => {}, @@ -118,15 +118,7 @@ describe('Claude published session close lifecycle', () => { task_type: 'local_agent', is_backgrounded: true }) - expect(backgroundStates).toEqual([ - { - state: 'monitoring', - tasks: [ - { id: 'background-1', kind: 'agent', state: 'working', startedAt: expect.any(Number) } - ], - supportsTaskStop: true - } - ]) + expect(childWork).toEqual(['live']) const session = ( adapter as unknown as { sessions: Map void } | null }> @@ -139,16 +131,8 @@ describe('Claude published session close lifecycle', () => { expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) expect(events.filter((event) => event.type === 'handle')).toHaveLength(0) expect(disposeTranslator).toHaveBeenCalledOnce() - expect(backgroundStates).toEqual([ - { - state: 'monitoring', - tasks: [ - { id: 'background-1', kind: 'agent', state: 'working', startedAt: expect.any(Number) } - ], - supportsTaskStop: true - }, - null - ]) + // A close Orca asked for stops the child still running, then the host hears the session end. + expect(childWork).toEqual(['live', 'ended', 'session-ended']) await expect(adapter.closeSession('session-1')).resolves.toBe(true) expect(persistHandle).toHaveBeenCalledTimes(2) diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index f49b6a8d88f9..1e19bf5600e1 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -6,6 +6,7 @@ import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' import { cancelClaudeAcquisitionAttempt } from './claude-structured-session-state' +import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { AgentSessionAcquisitionExitUnprovenError, AgentSessionAcquisitionRootExitObservedError, @@ -14,10 +15,10 @@ import { import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' -import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import { closeProcessRegistry } from '../../shared/child-process/close-process-registry' import { retireClaudeDispatchWaiters } from './claude-structured-dispatch' import { settledClaudeTurnEndLeaf } from './claude-structured-resume-point' +import { settleClaudeTurnEndWaiters } from './claude-request-end-wait' /** The root's own exit was seen first-hand. The lease follows the root, so a descendant * left unverified or seen alive does not hold it. */ @@ -68,6 +69,7 @@ export function settleClaudeExitedSession(session: ClaudeSession): void { // The child is gone, so no replay can start these turns. Nothing else ends a // waiter's life now that no deadline does. retireClaudeDispatchWaiters(session) + settleClaudeTurnEndWaiters(session) for (const prompt of session.prompts.clear()) { prompt.settle(null) } @@ -77,6 +79,8 @@ export function settleClaudeExitedSession(session: ClaudeSession): void { type CloseClaudePublishedSessionInput = { sessions: Map sessionId: string + /** Who asked for the close; the translator settles the open turn with it. */ + stopCause?: StructuredAgentSessionStopCause persistHandle?: (handle: { sessionId: string providerSessionId: string @@ -84,10 +88,6 @@ type CloseClaudePublishedSessionInput = { fence: number }) => Promise onEvent?: (event: ClaudeStructuredSessionEvent) => void - onBackgroundTasksChanged?: ( - sessionId: string, - state: AgentSessionBackgroundTaskState | null - ) => void } async function finalizeClaudePublishedSession( @@ -95,6 +95,7 @@ async function finalizeClaudePublishedSession( session: ClaudeSession ): Promise { retireClaudeDispatchWaiters(session) + settleClaudeTurnEndWaiters(session) // Settle every in-flight permission callback so closing leaves no dangling promise; `null` // writes no response, and the SDK ignores any post-cleanup answer regardless. for (const prompt of session.prompts.clear()) { @@ -116,10 +117,14 @@ async function finalizeClaudePublishedSession( } rootExitVerdict = cleanupError } - session.childWork.clear() - if (session.backgroundTasks.clear()) { - input.onBackgroundTasksChanged?.(input.sessionId, null) + // Queues the session's ending for the host's child records; the adapter delivers it after close. + // A close that proved the whole tree gone stopped what still ran. One that saw a descendant + // survive, like an exit of the session's own, leaves how it ended unknown. + if (connectionClosed === true) { + session.childWork.stopLive() } + session.childWork.clear() + session.backgroundTasks.clear() const leafUuid = await settledClaudeTurnEndLeaf(session) const persistence = session.closePersistence ?? @@ -135,6 +140,7 @@ async function finalizeClaudePublishedSession( type: 'ended', sessionId: input.sessionId, reason: 'claude session closed', + ...(input.stopCause ? { stopCause: input.stopCause } : {}), observedAt: Date.now() } as const let callbackError: unknown @@ -231,10 +237,6 @@ export function closeClaudePublishedSessionForDeps( fence: number }) => Promise onEvent?: (event: ClaudeStructuredSessionEvent) => void - onBackgroundTasksChanged?: ( - sessionId: string, - state: AgentSessionBackgroundTaskState | null - ) => void } ): Promise { return closeClaudePublishedSession({ sessions, sessionId, ...deps }) @@ -242,6 +244,7 @@ export function closeClaudePublishedSessionForDeps( export async function closeClaudeSession(input: { sessionId: string + stopCause?: StructuredAgentSessionStopCause sessions: Map acquisitions: ClaudeAcquisitionRegistry persistHandle?: (handle: { @@ -251,10 +254,6 @@ export async function closeClaudeSession(input: { fence: number }) => Promise onEvent?: (event: ClaudeStructuredSessionEvent) => void - onBackgroundTasksChanged?: ( - sessionId: string, - state: AgentSessionBackgroundTaskState | null - ) => void }): Promise { const attempt = input.acquisitions.get(input.sessionId) if (!(await cancelClaudeAcquisitionAttempt(attempt))) { diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index f4c59148241b..082f45726fe5 100644 --- a/src/main/claude/claude-structured-session-options.ts +++ b/src/main/claude/claude-structured-session-options.ts @@ -13,6 +13,7 @@ import { type ListedModel } from './claude-structured-model-catalog' import type { ClaudeSession } from './claude-structured-session-state' +import { structuredAgentSessionOptionModels } from '../native-chat/agent-session-wire/structured-agent-session-option-models' import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' /** @@ -240,6 +241,12 @@ function wireClaudeModels(models: readonly ListedModel[]): WireClaudeModel[] { return models.map(wireClaudeModel) } +/** The built-in models a running child lists when the CLI gives it none; a chat at rest with no + * catalog lists the same. */ +export function claudeFallbackModelOptions(): WireClaudeModel[] { + return wireClaudeModels(seedModels()) +} + /** The listing, with what the CLI runs when no effort is sent on each model the child applies — * a default only a running child knows, and only while this session has no effort pick. */ function catalogClaudeModels(session: ClaudeSession, discovered: ListedModel[]): WireClaudeModel[] { @@ -326,12 +333,13 @@ export function claudeStructuredSessionOptionsFrom( ): AgentSessionOptionsResult { const discovered = listedModels(catalog ? { models: catalog } : null) writeClaudeCatalogThrough(session, discovered) - const models = discovered.length > 0 ? discovered : seedModels() + const listed = discovered.length > 0 ? discovered : seedModels() const current = readClaudeCurrentModel(session) - const model = currentModelId(models, current.id) - if (!models.some((entry) => entry.id === model)) { - models.push({ id: model, label: model, isDefault: false, efforts: [], resolvedModel: null }) - } + const model = currentModelId(listed, current.id) + const models = structuredAgentSessionOptionModels(listed, model, (row) => ({ + ...row, + resolvedModel: null + })) const effort = session.options.get('effort') ?? session.reportedOptions.effort ?? diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index b40aaba81c44..2f0a262c3133 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -5,7 +5,10 @@ import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import type { StructuredAgentSessionStartedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { + StructuredAgentSessionStartedEvent, + StructuredAgentSessionStopCause +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection, openClaudeStreamJsonConnection @@ -19,14 +22,12 @@ import type { AgentModelCatalogSessionAccess, AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' -import type { - AgentSessionBackgroundTaskState, - AgentSessionFastModeState -} from '../../shared/agent-session-wire' +import type { AgentSessionFastModeState } from '../../shared/agent-session-wire' import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import type { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' import type { ClaudeChildWorkDecoder } from './claude-child-work-decoder' import type { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' +import type { ClaudeAtRestCommandCatalog } from './claude-at-rest-commands' import type { ClaudeSessionStartup } from './claude-structured-session-startup-state' export type ClaudeAuthDiagnostic = { @@ -73,6 +74,8 @@ export type ClaudeStructuredSessionEvent = failure?: SubmissionRejectionFact /** Present for first-hand child exits so the host can fence recovery. */ cause?: 'unexpected-exit' | 'requested-close' + /** Who asked for a close; the translator settles the open turn with it. */ + stopCause?: StructuredAgentSessionStopCause fence?: number acquisitionGeneration?: string /** Host clock when the end was observed. */ @@ -87,20 +90,20 @@ export type ClaudeLateDispatchOutcome = providerIdentity: AgentJournalItemIdentity } | ({ clientMessageId: string; state: 'rejected' } & AgentJournalDispatchRejection) + /** The CLI took the send and let it go unanswered: it may have run, so it is never re-sent. */ + | { clientMessageId: string; state: 'unknown'; reason: string } export type ClaudeStructuredSessionAdapterDeps = { + /** The `/` surface of a chat whose Claude is not running. */ + atRestCommands?: ClaudeAtRestCommandCatalog resolveLaunch: (input: { identity: AgentSessionJournalIdentity }) => Promise onEvent?: (event: ClaudeStructuredSessionEvent) => void /** Direct settlement path for provider-proven late dispatch outcomes. */ onDispatchSettledLate?: (input: { sessionId: string } & ClaudeLateDispatchOutcome) => void - /** The CLI reported `session_state_changed idle`, which it sends only once its queue drains. */ + /** The CLI reported `session_state_changed idle`: its turn is over. */ onSessionIdle?: (input: { sessionId: string }) => void - onBackgroundTasksChanged?: ( - sessionId: string, - state: AgentSessionBackgroundTaskState | null - ) => void /** What the session's child work did, delivered after the journal handled the frame. */ onChildWorkEvidence?: (sessionId: string, evidence: AgentChildWorkEvidence[]) => void openConnection?: typeof openClaudeStreamJsonConnection @@ -139,7 +142,7 @@ export type ClaudeDispatchWaiter = { requestedAt: number | null /** Set when the provider replay settled this waiter before send returned. */ settledUuid?: string - /** The write failed or the child died, but a replay may still name it. */ + /** Settled with no echo (failed write, child exit, doubt after start); a replay may name it. */ retired?: boolean /** The CLI's last non-terminal `command_lifecycle` state for this send; in memory only. */ commandLifecycle?: 'queued' | 'started' @@ -158,7 +161,7 @@ export type ClaudeSession = { acquisitionGeneration: string prompts: ClaudePromptRegistry dispatchWaiters: ClaudeDispatchWaiter[] - /** Bounded identities for dispatches whose child died or whose write failed. */ + /** Bounded identities for dispatches settled without an echo; a late replay still accepts one. */ retiredDispatchWaiters: ClaudeDispatchWaiter[] /** Once a retired waiter is evicted, legacy content-only replay matching is unsafe. */ replayContentFallbackBlocked: boolean diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts index 0380d5ac12fc..dd7ad1e8d8c5 100644 --- a/src/main/claude/claude-structured-session-test-support.ts +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -260,7 +260,7 @@ export function adapterAtPublishFor( persistedHandles: unknown[] = [], requestTimeoutMs?: number, persistHandle?: ClaudeStructuredSessionAdapterDeps['persistHandle'], - onBackgroundTasksChanged?: ClaudeStructuredSessionAdapterDeps['onBackgroundTasksChanged'], + onChildWorkEvidence?: ClaudeStructuredSessionAdapterDeps['onChildWorkEvidence'], onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate'] ): ClaudeStructuredSessionAdapter { return new ClaudeStructuredSessionAdapter({ @@ -285,7 +285,7 @@ export function adapterAtPublishFor( (async (handle) => { persistedHandles.push(handle) }), - ...(onBackgroundTasksChanged ? { onBackgroundTasksChanged } : {}), + ...(onChildWorkEvidence ? { onChildWorkEvidence } : {}), ...(onDispatchSettledLate ? { onDispatchSettledLate } : {}) }) } diff --git a/src/main/claude/claude-transcript-marker-branch-proof.test.ts b/src/main/claude/claude-transcript-marker-branch-proof.test.ts index befb320b9e58..51d3f03880c2 100644 --- a/src/main/claude/claude-transcript-marker-branch-proof.test.ts +++ b/src/main/claude/claude-transcript-marker-branch-proof.test.ts @@ -277,44 +277,6 @@ describe('Claude transcript marker branch proof', () => { ).rejects.toThrow('not on the main transcript') }) - it('rejects a latest marker descended from a parent-tool-use cursor sidechain', async () => { - const root = await makeRoot('orca-native-chat-resolve-claude-parent-tool-cursor-descendant-') - const transcript = join(root, 'transcript.jsonl') - await writeFile( - transcript, - [ - { type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' }, - { - type: 'assistant', - uuid: 'subagent-assistant', - parentUuid: 'main-user', - sessionId: 'session-1', - parent_tool_use_id: 'tool-use-1' - }, - { - type: 'assistant', - uuid: 'main-after-sidechain', - parentUuid: 'subagent-assistant', - sessionId: 'session-1' - }, - { - type: 'assistant', - uuid: 'latest-after-sidechain', - parentUuid: 'main-after-sidechain', - sessionId: 'session-1' - }, - { type: 'last-prompt', leafUuid: 'latest-after-sidechain', sessionId: 'session-1' } - ] - .map((record) => JSON.stringify(record)) - .join('\n'), - 'utf8' - ) - - await expect( - proveMarkerLeafUuid(transcript, 'session-1', 'main-after-sidechain') - ).rejects.toThrow('not on the main transcript') - }) - it('rejects a post-snapshot descendant whose parent row was observed later', async () => { const root = await makeRoot('orca-native-chat-resolve-claude-post-snapshot-') const transcript = join(root, 'transcript.jsonl') diff --git a/src/main/claude/claude-turn-lifecycle-item.ts b/src/main/claude/claude-turn-lifecycle-item.ts index be7150531fe0..da3af8823093 100644 --- a/src/main/claude/claude-turn-lifecycle-item.ts +++ b/src/main/claude/claude-turn-lifecycle-item.ts @@ -5,6 +5,7 @@ import type { } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' +import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentSessionAppendOptions } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { claudeResultOutcome } from './claude-result-outcome' import type { ClaudeCommandTurn } from './claude-command-turn' @@ -31,9 +32,9 @@ export function claudeCurrentTurnIdentity(turn: ClaudeCurrentTurn): AgentJournal export type ClaudeTurnEnd = { state: 'completed' | 'interrupted' completedAt: number - /** Only an end the PROVIDER reported carries one. An end the host inferred — - * the child going away, a new turn superseding this one — leaves it absent, - * which reads as unknown rather than claiming the turn worked. */ + /** An end the provider reported carries its verdict, and a turn a newer one + * replaced carries `superseded`. The child going away leaves it absent, which + * reads as unknown rather than claiming the turn worked. */ outcome?: AgentJournalTurnOutcome /** The SDK's own measured turn duration; only a result frame carries one. */ durationMs?: number @@ -44,9 +45,10 @@ export type ClaudeTurnEnd = { * is still a turn the host watched finish, and only `outcome` says it failed. */ export function claudeTurnEndForResult( message: Record, - completedAt: number + completedAt: number, + stop: StructuredAgentSessionStopCause | null = null ): ClaudeTurnEnd { - const outcome = claudeResultOutcome(message) + const outcome = claudeResultOutcome(message, stop) const durationMs = message.duration_ms return { state: outcome === 'cancellation' ? 'interrupted' : 'completed', diff --git a/src/main/claude/claude-turn-outcome.test.ts b/src/main/claude/claude-turn-outcome.test.ts index 8223652458bf..0dfb3080710e 100644 --- a/src/main/claude/claude-turn-outcome.test.ts +++ b/src/main/claude/claude-turn-outcome.test.ts @@ -1,12 +1,16 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody, - AgentJournalItemIdentity + AgentJournalItemIdentity, + AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { readAgentJournalTurn, readAgentJournalTurnOutcome } from '../../shared/agent-session-turn-record' +import { describeNativeChatTurnStatus } from '../../shared/native-chat-turn-status' +import { selectStructuredAgentSettledTurns } from '../../shared/structured-agent-session-turn-timing' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { claudeResultOutcome } from './claude-result-outcome' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' @@ -162,23 +166,12 @@ describe('claudeTurnLifecycleItem', () => { }) describe('a turn end the host inferred', () => { - it.each([ - [ - 'the child ending', - (translator: ReturnType) => - translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' }) - ], - [ - 'a new turn superseding it', - (translator: ReturnType) => - translator.handle(userTurn('user-2')) - ] - ])('records no outcome for a turn ended by %s', (_label, end) => { + it('records no outcome for a turn ended by the child ending', () => { const state = sinkState() const translator = createClaudeJournalTranslator({ sink: state.sink }) translator.handle(userTurn('user-1')) - end(translator) + translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' }) // No result frame arrived, so the provider never said what became of the // turn. The host observed the END — the arm stays `interrupted` — but the @@ -194,4 +187,161 @@ describe('a turn end the host inferred', () => { expect(settled?.body).not.toHaveProperty('outcome') expect(readAgentJournalTurnOutcome(readAgentJournalTurn(settled?.body))).toBeNull() }) + + // The supersede fires for any send Orca dispatched, and nothing here says whether the user or + // another agent sent it, so it is recorded as replaced, never as the user's stop or a fault. + it('records a turn a newer send superseded as superseded, folded as interrupted', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + let observedAt = 1_000 + translator.handle({ ...userTurn('user-1'), observedAt }) + observedAt = 13_000 + translator.handle({ ...userTurn('user-2'), observedAt }) + + // The journal as a reader holds it: each turn row at its newest revision, after the user row + // it names (the host journals that row, not this translator). + const rows = new Map() + state.items.forEach((item, index) => { + const itemId = agentJournalItemKey(item.identity) + rows.set(itemId, { itemId, revision: index, sequence: index, observedAt, body: item.body }) + }) + const items = [...rows.values()].flatMap((row): AgentJournalRenderItem[] => { + const userItemId = readAgentJournalTurn(row.body)?.userItemId + return userItemId + ? [ + { + ...row, + itemId: userItemId, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] } + }, + row + ] + : [row] + }) + const superseded = items.find((item) => readAgentJournalTurn(item.body)?.turnId === 'user-1') + expect(readAgentJournalTurn(superseded?.body)).toMatchObject({ + state: 'interrupted', + outcome: 'superseded' + }) + const settled = selectStructuredAgentSettledTurns(items).get( + readAgentJournalTurn(superseded?.body)?.userItemId ?? '' + ) + expect(settled).toMatchObject({ verdict: 'superseded', workedSeconds: 12 }) + expect( + settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) + ).toMatchObject({ key: 'interruptedAfter', duration: '12s' }) + }) +}) + +describe("a user's Stop inside a live turn", () => { + // Claude CLIs before 2.1.91 send no terminal_reason, and later ones may omit it. + const cutShort = { type: 'result', subtype: 'error_during_execution', is_error: true } + + function settledTurn(items: ReturnType['items'], turnId: string) { + return items + .map((item) => readAgentJournalTurn(item.body)) + .findLast((turn) => turn?.turnId === turnId && turn.state !== 'running') + } + + function providerRows(items: ReturnType['items']): number { + return items.filter( + (item) => + item.identity.provider === 'orca' && + item.identity.clientMessageId.startsWith('provider-frame:') + ).length + } + + it('reads an error result with no terminal reason as the cancellation it asked for', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + translator.handle(userTurn('user-1')) + + expect(translator.recordTurnStop('user-1', 'user-stop')).toBe(true) + translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) + + expect(settledTurn(state.items, 'user-1')).toMatchObject({ + state: 'interrupted', + outcome: 'cancellation' + }) + expect(providerRows(state.items)).toBe(0) + }) + + it.each([ + ['no terminal reason', cutShort], + [ + 'a failure terminal reason', + { ...cutShort, terminal_reason: 'api_error', result: 'API Error' } + ] + ])('keeps a result with %s and no Stop a failure', (_label, frame) => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + translator.handle(userTurn('user-1')) + + translator.handle({ type: 'message', sessionId: 'orca-session', message: frame }) + + expect(settledTurn(state.items, 'user-1')).toMatchObject({ + state: 'completed', + outcome: 'failure' + }) + expect(providerRows(state.items)).toBe(1) + }) + + it('keeps a turn that finished during the Stop a success', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + translator.handle(userTurn('user-1')) + + translator.recordTurnStop('user-1', 'user-stop') + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'result', subtype: 'success', is_error: false } + }) + + expect(settledTurn(state.items, 'user-1')).toMatchObject({ outcome: 'success' }) + }) + + it('does not carry a Stop onto the next turn', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + translator.handle(userTurn('user-1')) + translator.recordTurnStop('user-1', 'user-stop') + // Superseded before its result: the Stop was for user-1 only. + translator.handle(userTurn('user-2')) + translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) + // A late Stop names a turn that already ended. + expect(translator.recordTurnStop('user-2', 'user-stop')).toBe(false) + translator.handle(userTurn('user-3')) + translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) + + expect(settledTurn(state.items, 'user-2')).toMatchObject({ outcome: 'failure' }) + expect(settledTurn(state.items, 'user-3')).toMatchObject({ outcome: 'failure' }) + }) + + it('ends the Stop with its turn, so a result after the turn settled reads on its own', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + translator.handle(userTurn('user-1')) + translator.recordTurnStop('user-1', 'user-stop') + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'system', subtype: 'session_state_changed', state: 'idle' } + }) + + translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) + + expect(providerRows(state.items)).toBe(1) + }) + + it('does not read a host stop as the user asking', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + translator.handle(userTurn('user-1')) + + translator.recordTurnStop('user-1', 'host-stop') + translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) + + expect(settledTurn(state.items, 'user-1')).toMatchObject({ outcome: 'failure' }) + }) }) diff --git a/src/main/claude/claude-turn-ownership.test.ts b/src/main/claude/claude-turn-ownership.test.ts index 6bfcb32cb863..f150dd362b62 100644 --- a/src/main/claude/claude-turn-ownership.test.ts +++ b/src/main/claude/claude-turn-ownership.test.ts @@ -93,8 +93,13 @@ function sessionHoldingTurn(turnId: string | null): ReturnType () => {}, + cancel: () => ({ accepted: true }) + }, currentTurnId: turnId, + recordTurnStop: () => true, commandTurnId: null, beginCommand: vi.fn(), forgetCommand: vi.fn(), @@ -131,8 +136,7 @@ function cancellationOf( ): Promise<{ cancelled: boolean }> { return cancelClaudeStructuredTurn({ request, - sessions: new Map([['session-1', session]]), - admitPromptCancellation: () => true + sessions: new Map([['session-1', session]]) }) } diff --git a/src/main/claude/hook-script.ts b/src/main/claude/hook-script.ts index 475d79273b44..b2e98ba229fc 100644 --- a/src/main/claude/hook-script.ts +++ b/src/main/claude/hook-script.ts @@ -1,3 +1,4 @@ +import { buildHookProcessCapture } from '../agent-hooks/hook-process-capture' /** The managed Claude-compatible hook script, built for local, POSIX-remote and Windows targets. * Split from hook-service.ts so the service owns install/status and this owns script text, * mirroring the same split under src/main/cursor/. */ @@ -78,6 +79,7 @@ export function getManagedScript( 'if [ -n "$CLAUDE_JOB_DIR" ]; then', ' exit 0', 'fi', + ...(source === 'claude' ? buildHookProcessCapture() : []), // Why: refresh endpoint coordinates for PTYs surviving an Orca restart. // Why: suppress parse errors so they neither leak nor trip outer set -e. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', diff --git a/src/main/codex-usage/scanner.test.ts b/src/main/codex-usage/scanner.test.ts index 0e899028b29f..bc5e3a007cef 100644 --- a/src/main/codex-usage/scanner.test.ts +++ b/src/main/codex-usage/scanner.test.ts @@ -236,39 +236,6 @@ describe('attributeCodexUsageEvent', () => { expect(attributed?.worktreeId).toBe('repo-2::/workspace/repo/app2') }) - it('attributes cwd paths under dotdot-prefixed child directories to the worktree', async () => { - const attributed = await attributeCodexUsageEvent( - { - sessionId: 'session-1', - timestamp: '2026-04-09T10:00:00.000Z', - eventKey: 'event-1', - cwd: '/workspace/repo/..fixtures/session', - model: 'gpt-5.2-codex', - hasInferredPricing: false, - longContextInputTokens: 0, - longContextCachedInputTokens: 0, - longContextOutputTokens: 0, - inputTokens: 100, - cachedInputTokens: 10, - outputTokens: 25, - reasoningOutputTokens: 10, - totalTokens: 125 - }, - await createUsageWorktreeResolver([ - { - repoId: 'repo-1', - worktreeId: 'repo-1::/workspace/repo', - path: '/workspace/repo', - displayName: 'Repo' - } - ]) - ) - - expect(attributed?.projectKey).toBe('worktree:repo-1::/workspace/repo') - expect(attributed?.projectLabel).toBe('Repo') - expect(attributed?.worktreeId).toBe('repo-1::/workspace/repo') - }) - it('does not attribute true parent-directory escapes to the worktree', async () => { const attributed = await attributeCodexUsageEvent( { @@ -300,36 +267,4 @@ describe('attributeCodexUsageEvent', () => { expect(attributed?.projectKey).toBe('cwd:/workspace/repo/../other/session') expect(attributed?.worktreeId).toBeNull() }) - - it('does not treat different Windows drives as containing paths', async () => { - const attributed = await attributeCodexUsageEvent( - { - sessionId: 'session-1', - timestamp: '2026-04-09T10:00:00.000Z', - eventKey: 'event-1', - cwd: 'D:\\other\\repo', - model: 'gpt-5.2-codex', - hasInferredPricing: false, - longContextInputTokens: 0, - longContextCachedInputTokens: 0, - longContextOutputTokens: 0, - inputTokens: 100, - cachedInputTokens: 10, - outputTokens: 25, - reasoningOutputTokens: 10, - totalTokens: 125 - }, - await createUsageWorktreeResolver([ - { - repoId: 'repo-1', - worktreeId: 'repo-1::C:\\repo', - path: 'C:\\repo', - displayName: 'Repo' - } - ]) - ) - - expect(attributed?.projectKey).toBe('cwd:d:/other/repo') - expect(attributed?.worktreeId).toBeNull() - }) }) diff --git a/src/main/codex/codex-background-command-tracker.ts b/src/main/codex/codex-background-command-tracker.ts index 922743f1a831..e7d19e3bbccf 100644 --- a/src/main/codex/codex-background-command-tracker.ts +++ b/src/main/codex/codex-background-command-tracker.ts @@ -1,4 +1,8 @@ import type { AgentSessionBackgroundTask } from '../../shared/agent-session-wire' +import { + boundCodexCommandDescription, + codexChildCommandDescription +} from '../../shared/codex-child-command-description' import type { CodexBackgroundTaskEvent } from './codex-background-task-frames' import { readRecord, readString } from './codex-item-field-readers' import { readCodexThreadItem } from './codex-structured-item-translation' @@ -6,7 +10,6 @@ import { MAX_CODEX_ITEM_STREAM_METADATA_BYTES } from './codex-item-stream-retent import type { CodexAbandonedCommand } from './codex-prompt-registry' const MAX_SETTLED_COMMANDS = 128 -const MAX_DESCRIPTION_CHARS = 512 type Command = { threadId: string; task: AgentSessionBackgroundTask; bytes: number } @@ -15,35 +18,6 @@ export type CodexBackgroundCommandChange = | { type: 'started'; threadId: string; task: AgentSessionBackgroundTask } | { type: 'ended'; threadId: string; taskId: string } -/** The label's reserved share of the description. Reserved, not merely capped: - * a label free to spend the whole budget clips away the command it qualifies, - * leaving a command row naming an agent and no command — the failure this - * qualification exists to remove, in the other direction. `bytes` is counted - * before qualification, so this share is also what a published row may exceed - * the admitted count by. */ -const MAX_LABEL_CHARS = 96 - -/** Every cut in this file goes through here, clipped the way `boundSubagentField` - * clips the same provider string on the agent row: never mid surrogate pair, - * since a lone surrogate is lossy through any non-JSON UTF-8 hop. A composed - * row is cut a SECOND time, so a clip that is safe only where the label is - * bounded is not safe. No ordinal, because a row's identity is its `id`. */ -function boundText(value: string, max: number): string { - if (value.length <= max) { - return value - } - const keep = max - 1 - const last = value.charCodeAt(keep - 1) - const end = last >= 0xd800 && last <= 0xdbff ? keep - 1 : keep - return `${value.slice(0, end)}…` -} - -/** Resolved on read, and capped at the bound the admitted description already respects. */ -function qualifiedDescription(label: string, description: string | undefined): string { - const name = boundText(label, MAX_LABEL_CHARS) - return boundText(description ? `${name} — ${description}` : name, MAX_DESCRIPTION_CHARS) -} - export class CodexBackgroundCommandTracker { private readonly commands = new Map() private readonly settled = new Map() @@ -115,7 +89,7 @@ export class CodexBackgroundCommandTracker { // a label registered after the command still lands. const label = threadId === this.primaryThreadId ? null : childLabel?.(threadId) return label - ? { ...task, description: qualifiedDescription(label, task.description) } + ? { ...task, description: codexChildCommandDescription(label, task.description) } : task }) } @@ -189,7 +163,7 @@ export class CodexBackgroundCommandTracker { } const key = JSON.stringify([event.threadId, item.id]) const completed = event.method === 'item/completed' || item.status !== 'inProgress' - const description = boundText(readString(item, 'command') ?? '', MAX_DESCRIPTION_CHARS) + const description = boundCodexCommandDescription(readString(item, 'command') ?? '') .replace(/\s+/g, ' ') .trim() const value = { diff --git a/src/main/codex/codex-background-task-frames.ts b/src/main/codex/codex-background-task-frames.ts index f01863956b6c..06536a857b8c 100644 --- a/src/main/codex/codex-background-task-frames.ts +++ b/src/main/codex/codex-background-task-frames.ts @@ -1,22 +1,23 @@ import type { NativeChatSubagentState } from '../../shared/native-chat-types' -import { - codexSubagentLabel, - isCodexRootAgentActivity, - readCodexSubagentActivity -} from './codex-subagent-activity' +import { readCodexSubagentAnnouncements } from './codex-subagent-activity' import { codexChildTurnState } from './codex-subagent-executions' import { readRecord } from './codex-item-field-readers' import { readCodexThreadItem } from './codex-structured-item-translation' import { readCodexTurnId } from './codex-structured-thread-facts' +export type CodexAnnouncedChild = { + agentThreadId: string + label: string | null + parentTurnId: string | null | undefined + /** The reporting thread, for a spawn: the agent that spawned the child. */ + spawnerThreadId: string | undefined +} + export type CodexBackgroundTaskFrame = | { - kind: 'subagent' - agentThreadId: string - label: string | null - parentTurnId: string | null | undefined - /** The reporting thread, for a `started` activity: the agent that spawned the child. */ - spawnerThreadId: string | undefined + /** An item naming children, several for a call on more than one helper. */ + kind: 'subagents' + children: CodexAnnouncedChild[] } | { kind: 'turn' @@ -78,23 +79,14 @@ export function readCodexBackgroundTaskFrame( return null } const item = readCodexThreadItem(readRecord(event.params).item) - const activity = item && readCodexSubagentActivity(item) - if ( - !activity || - activity.agentThreadId === primaryThreadId || - isCodexRootAgentActivity(activity) - ) { - return null - } - return { - kind: 'subagent', - agentThreadId: activity.agentThreadId, - label: codexSubagentLabel(activity), - parentTurnId: - activity.kind === 'started' || activity.kind === 'interacted' - ? readCodexTurnId(event.params) - : undefined, - // Only `started` names the spawner: other kinds ride whichever agent acted. - spawnerThreadId: activity.kind === 'started' ? event.threadId : undefined - } + const children = (item ? readCodexSubagentAnnouncements(item, primaryThreadId) : []).map( + (announcement) => ({ + agentThreadId: announcement.agentThreadId, + label: announcement.label, + parentTurnId: announcement.namesParentTurn ? readCodexTurnId(event.params) : undefined, + // Only a spawn names the spawner: other announcements ride whichever agent acted. + spawnerThreadId: announcement.spawned ? event.threadId : undefined + }) + ) + return children.length > 0 ? { kind: 'subagents', children } : null } diff --git a/src/main/codex/codex-background-task-tracker.test.ts b/src/main/codex/codex-background-task-tracker.test.ts index 0a70442a0b37..6c32896140b3 100644 --- a/src/main/codex/codex-background-task-tracker.test.ts +++ b/src/main/codex/codex-background-task-tracker.test.ts @@ -76,10 +76,8 @@ function command( describe('readCodexBackgroundTaskFrame', () => { it('reads activity as child metadata without inferring execution state', () => { expect(readCodexBackgroundTaskFrame(activity('interacted'), PRIMARY)).toEqual({ - kind: 'subagent', - agentThreadId: CHILD, - label: 'count_a', - parentTurnId: PARENT_TURN + kind: 'subagents', + children: [{ agentThreadId: CHILD, label: 'count_a', parentTurnId: PARENT_TURN }] }) }) diff --git a/src/main/codex/codex-background-task-tracker.ts b/src/main/codex/codex-background-task-tracker.ts index 6429a84d4fda..9fd2880dc75e 100644 --- a/src/main/codex/codex-background-task-tracker.ts +++ b/src/main/codex/codex-background-task-tracker.ts @@ -73,13 +73,15 @@ export class CodexBackgroundTaskTracker { : []) ] const frame = readCodexBackgroundTaskFrame(event, this.primaryThreadId) - if (frame?.kind === 'subagent') { - this.executions.register( - frame.agentThreadId, - frame.label, - frame.parentTurnId, - frame.spawnerThreadId - ) + if (frame?.kind === 'subagents') { + for (const child of frame.children) { + this.executions.register( + child.agentThreadId, + child.label, + child.parentTurnId, + child.spawnerThreadId + ) + } } else if (frame?.kind === 'thread-closed') { this.executions.closeThread(frame.threadId) } else if (frame && frame.threadId !== this.primaryThreadId) { diff --git a/src/main/codex/codex-child-work-evidence.ts b/src/main/codex/codex-child-work-evidence.ts index 93bda7a54a1f..af946afeedc0 100644 --- a/src/main/codex/codex-child-work-evidence.ts +++ b/src/main/codex/codex-child-work-evidence.ts @@ -100,15 +100,13 @@ export class CodexChildWorkEvidence { commands: readonly CodexBackgroundCommandChange[] ): void { this.queueCommands(commands) - const threadId = this.childThread(event, frame) - if (threadId === null) { - return - } - const facts = this.factsFor(threadId) - if (facts && event.threadId === threadId) { - this.record(facts, event) + for (const threadId of this.childThreads(event, frame)) { + const facts = this.factsFor(threadId) + if (facts && event.threadId === threadId) { + this.record(facts, event) + } + this.queueChild(threadId) } - this.queueChild(threadId) } /** The provider session is gone, with the commands it ended: no child it still ran can report @@ -125,17 +123,17 @@ export class CodexChildWorkEvidence { return pending.map((edge) => edge(observedAt)) } - private childThread( + private childThreads( event: CodexBackgroundTaskEvent, frame: CodexBackgroundTaskFrame | null - ): string | null { - const threadId = - frame?.kind === 'subagent' - ? frame.agentThreadId + ): string[] { + const threadIds = + frame?.kind === 'subagents' + ? frame.children.map((child) => child.agentThreadId) : frame || CHILD_FRAME_METHODS.has(event.method) - ? event.threadId - : null - return threadId === this.primaryThreadId ? null : threadId + ? [event.threadId] + : [] + return threadIds.filter((threadId) => threadId !== this.primaryThreadId) } /** A command belongs to the child thread that launched it; the session's own agent is no owner. diff --git a/src/main/codex/codex-close-stop-cause.test.ts b/src/main/codex/codex-close-stop-cause.test.ts new file mode 100644 index 000000000000..69618f22dd87 --- /dev/null +++ b/src/main/codex/codex-close-stop-cause.test.ts @@ -0,0 +1,158 @@ +// Closing a Codex child settles its open turn in the adapter, with the cause the host handed the +// close. Only a stop the user aimed at this chat reads as their cancellation. + +import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' +import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import { CodexPromptRegistry } from './codex-structured-prompt-replies' +import { + closeCodexPublishedSession, + handleCodexSessionExit +} from './codex-structured-session-close' +import { CodexAcquisitionRegistry, type CodexSession } from './codex-structured-session-state' +import { CodexStructuredSessionTeardown } from './codex-structured-session-teardown' + +/** A live Codex child whose primary thread is inside `turn-1`, and what its ended batch wrote. */ +function sessionWithRunningTurn() { + const turnBodies: AgentJournalItemBody[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {}, + tryAppendLifecycleBatch: (_id, mutations) => { + for (const mutation of mutations) { + if (mutation.kind === 'item' && mutation.body.kind === 'turn') { + turnBodies.push(mutation.body) + } + } + return { accepted: true } + } + } + const translator = createCodexJournalTranslator({ + sink, + sessionId: 'session-1', + primaryThreadId: () => 'thread-1', + now: () => 2_000 + }) + translator.handle({ + type: 'notification', + sessionId: 'session-1', + threadId: 'thread-1', + method: 'turn/started', + params: { turn: { id: 'turn-1' } }, + observedAt: 1_000 + }) + const session: CodexSession = { + connection: { + pid: 4321, + closed: false, + request: async () => ({}), + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => true + }, + backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), + ended: false, + requestedClose: false, + fence: 7, + acquisitionGeneration: 'generation-1', + threadId: 'thread-1', + historyPath: null, + prompts: new CodexPromptRegistry(), + options: new Map(), + reportedOptions: {}, + fastModeTierByModel: new Map(), + dispatchEchoes: createCodexDispatchEchoes(), + turnOpenWaits: createCodexTurnOpenWaits(), + translator + } + return { sessions: new Map([['session-1', session]]), session, turnBodies } +} + +describe('a Codex close settles the open turn with the host-named cause', () => { + it.each(['user-close', 'user-stop'] satisfies StructuredAgentSessionStopCause[])( + "records the user's %s as their cancellation", + async (stopCause) => { + const { sessions, turnBodies } = sessionWithRunningTurn() + const onEvent = vi.fn() + + await expect( + closeCodexPublishedSession(sessions, 'session-1', onEvent, { stopCause }) + ).resolves.toBe(true) + + expect(turnBodies).toEqual([ + expect.objectContaining({ turnId: 'turn-1', state: 'interrupted', outcome: 'cancellation' }) + ]) + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'ended', cause: 'requested-close', stopCause }) + ) + } + ) + + it.each([['evict'], ['host-stop'], [undefined]] as const)( + 'leaves a close for %s as news', + async (stopCause) => { + const { sessions, turnBodies } = sessionWithRunningTurn() + + await closeCodexPublishedSession( + sessions, + 'session-1', + undefined, + stopCause ? { stopCause } : {} + ) + + expect(turnBodies).toEqual([ + expect.objectContaining({ turnId: 'turn-1', state: 'interrupted' }) + ]) + expect(turnBodies[0]).not.toHaveProperty('outcome') + } + ) + + it('leaves a crash it saw before the user closed the chat as news', async () => { + const { sessions, session, turnBodies } = sessionWithRunningTurn() + // The child died on its own first; the user's close then finds it already ended. + handleCodexSessionExit({ + sessions, + sessionId: 'session-1', + connection: session.connection, + error: new Error('app-server exited') + }) + + await closeCodexPublishedSession(sessions, 'session-1', undefined, { stopCause: 'user-close' }) + + expect(turnBodies).toEqual([ + expect.objectContaining({ turnId: 'turn-1', state: 'interrupted' }) + ]) + expect(turnBodies[0]).not.toHaveProperty('outcome') + }) + + it('never carries a user cause onto a close forced after a sink failure', async () => { + const { sessions, turnBodies } = sessionWithRunningTurn() + + await closeCodexPublishedSession(sessions, 'session-1', undefined, { + requestedClose: false, + stopCause: 'user-close' + }) + + expect(turnBodies[0]).not.toHaveProperty('outcome') + }) + + it("carries the host's cause from the adapter's close to the ended batch", async () => { + const { sessions, turnBodies } = sessionWithRunningTurn() + const teardown = new CodexStructuredSessionTeardown({ + sessions, + acquisitions: new CodexAcquisitionRegistry(), + forgetNotificationRetries: () => {} + }) + + await expect(teardown.close('session-1', 'user-close')).resolves.toBe(true) + + expect(turnBodies[0]).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + }) +}) diff --git a/src/main/codex/codex-collab-agent-item-translation.ts b/src/main/codex/codex-collab-agent-item-translation.ts new file mode 100644 index 000000000000..f12d22b7339d --- /dev/null +++ b/src/main/codex/codex-collab-agent-item-translation.ts @@ -0,0 +1,154 @@ +// A Codex `collabAgentToolCall` item → a tool row that names the helper it acted on. +// +// The row is the call the agent made (`spawn_agent`, `wait_agent`, `close_agent`, …); the helper +// itself is the subagent roster's row. A helper is named the way the roster names it, so the two +// rows read as the same child. + +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import { CODEX_COLLAB_ROW_AGENTS_KEY } from '../../shared/codex-collab-agent-tools' +import { + boundInlineText, + boundToolInput, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import { + codexCollabHelperLabel, + codexCollabToolName, + readCodexCollabAgentToolCall, + type CodexCollabAgentToolCall +} from './codex-collab-agent-tool-call' +import { readString } from './codex-item-field-readers' +import { codexItemRunState } from './codex-item-run-state' +import type { CodexThreadItem } from './codex-thread-item-identity' + +/** The roster's name for a helper thread, or null for one it holds no name for. */ +export type CodexHelperName = (threadId: string) => string | null + +/** Who the call acted on. A spawn names its helper by its prompt until the roster holds the + * thread it became; a helper with no name (its spawn was never seen) is named by its thread id. */ +function helperNames(call: CodexCollabAgentToolCall, helperName?: CodexHelperName): string { + if (call.tool === 'spawnAgent') { + const spawned = call.receiverThreadIds[0] + return (spawned && helperName?.(spawned)) || codexCollabHelperLabel(call.prompt) || '' + } + return call.receiverThreadIds.map((threadId) => helperName?.(threadId) ?? threadId).join(', ') +} + +/** Codex's own words for a helper's `CollabAgentStatus`, as its client shows them. */ +const HELPER_STATUS_TEXT = new Map([ + ['pendingInit', 'Pending init'], + ['running', 'Running'], + ['interrupted', 'Interrupted'], + ['completed', 'Completed'], + ['shutdown', 'Shutdown'], + ['notFound', 'Not found'] +]) + +const CALL_STATUS_TEXT = new Map([ + ['completed', 'Completed'], + ['failed', 'Failed'], + ['interrupted', 'Interrupted'] +]) + +type HelperState = CodexCollabAgentToolCall['states'][number] + +/** A helper's status summarised as Codex's own client does: a completed helper with its last + * reply, an errored one with its error. */ +function statusSummary(state: HelperState): string | null { + if (state.status === 'errored') { + return `Error - ${state.message ?? 'Agent errored'}` + } + if (state.status === 'completed' && state.message) { + return `Completed - ${state.message}` + } + return state.status === null ? null : (HELPER_STATUS_TEXT.get(state.status) ?? state.status) +} + +/** A finished call's output, worded as Codex's own client words the call. Every finished call has + * one: a client that pairs results by position (one predating result call ids) would otherwise + * draw each later output in the run under the call before its own. The row label already names + * the helper, so the output does not. */ +function outputText(call: CodexCollabAgentToolCall, helperName?: CodexHelperName): string | null { + if (call.status === null || call.status === 'inProgress') { + return null + } + // The status a spawn or close reports predates what it did (a closed helper reads `running`), + // so these say what the call did. + switch (call.tool) { + case 'spawnAgent': + return call.receiverThreadIds.length > 0 ? 'Spawned' : 'Agent spawn failed' + case 'sendInput': + return 'Sent input' + case 'closeAgent': + return 'Closed' + case 'resumeAgent': + return (call.states[0] && statusSummary(call.states[0])) ?? 'Error - Agent resume failed' + } + const reports = call.states.flatMap((state) => { + // A wait returns what each finished helper said. + const text = + call.tool === 'wait' && state.status === 'completed' && state.message + ? state.message + : statusSummary(state) + return text === null ? [] : [{ threadId: state.threadId, text }] + }) + if (reports.length === 0) { + // A wait's end names only helpers that finished (none when it timed out; v2's never names any). + return call.tool === 'wait' + ? 'Finished waiting' + : (CALL_STATUS_TEXT.get(call.status) ?? call.status) + } + if (reports.length === 1 && call.receiverThreadIds.length === 1) { + return reports[0].text + } + return reports + .map(({ threadId, text }) => `${helperName?.(threadId) ?? threadId}: ${text}`) + .join('\n') +} + +/** The prompt is clipped on its own: bounded with the rest, a long one would clip away the + * helper's name and ids the row is read by. */ +function collabRowInput(fields: Record, prompt: string | null): unknown { + const bounded = boundToolInput(fields, DEFAULT_JOURNAL_PAYLOAD_LIMITS) + if (bounded !== fields) { + return bounded + } + const input = prompt + ? { ...fields, prompt: boundInlineText(prompt, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text } + : fields + return Object.keys(input).length > 0 ? input : null +} + +/** `started` is the call's started item, when the caller still holds it. */ +export function codexCollabAgentToolCallBody( + item: CodexThreadItem, + helperName?: CodexHelperName, + started?: CodexThreadItem +): AgentJournalItemBody | null { + const call = readCodexCollabAgentToolCall(item, started) + if (!call) { + return null + } + const description = helperNames(call, helperName) + const model = readString(item, 'model') + const reasoningEffort = readString(item, 'reasoningEffort') + const fields = { + // `description` is the key the row label reads, so the helper's name leads the row. + ...(description ? { description } : {}), + ...(model ? { model } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(call.receiverThreadIds.length > 0 + ? { [CODEX_COLLAB_ROW_AGENTS_KEY]: call.receiverThreadIds } + : {}) + } + const text = outputText(call, helperName) + const output = text === null ? null : boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS) + return { + kind: 'tool-call', + name: codexCollabToolName(call), + callId: call.id, + input: collabRowInput(fields, call.prompt), + state: codexItemRunState(item), + ...(output === null ? {} : { output: output.bounded }) + } +} diff --git a/src/main/codex/codex-collab-agent-tool-call.ts b/src/main/codex/codex-collab-agent-tool-call.ts new file mode 100644 index 000000000000..01809a7fb51d --- /dev/null +++ b/src/main/codex/codex-collab-agent-tool-call.ts @@ -0,0 +1,102 @@ +// Reading Codex's `collabAgentToolCall` items: the calls an agent makes to spawn, message, wait on +// and close its helpers. +// +// Codex's default multi-agent mode reports a helper ONLY this way; it emits no `subAgentActivity`. +// Shapes are from the app-server's generated schema (0.155) and a live default-mode session: +// * `spawnAgent` starts with `receiverThreadIds: []`. The helper's thread id first appears when +// the call ends, beside the `prompt` it was given — even when the call ends `failed`. +// * Every other call names its helpers from its start. A finished call reports a thread id it +// does not know as `notFound`; any other state says the helper exists. +// * Codex core also knows each receiver's nickname and role, but the app-server item does not +// carry them yet; `readCodexSubagentAnnouncements` is where they would be adopted. +// * The item names no nickname or task path, so the prompt is the only text that tells one +// helper from another. +// * A wait that times out ends naming no receiver and no state. +// * `agentsStates` is the caller's last-known snapshot of each receiver. The helper's own turn +// frames own its execution, so nothing here reads it as execution state: only `notFound`, +// which says the receiver is no helper, and the snapshot a wait or resume reports, which its +// row shows as output. A spawn's or close's predates what the call did, so its row does not. + +import { codexCollabToolRowName } from '../../shared/codex-collab-agent-tools' +import { collapsedToolInputPrefix } from '../../shared/native-chat-tool-preview-prefix' +import { readRecord, readString } from './codex-item-field-readers' +import type { CodexThreadItem } from './codex-thread-item-identity' + +export const CODEX_COLLAB_AGENT_TOOL_CALL_ITEM_TYPE = 'collabAgentToolCall' + +/** Row length for a helper named by its prompt: long enough to tell two helpers apart. */ +const MAX_HELPER_LABEL_CHARS = 80 + +export type CodexCollabAgentToolCall = { + id: string + /** The schema's `CollabAgentTool`: `spawnAgent`, `sendInput`, `wait`, `closeAgent`, … */ + tool: string + /** `inProgress`, `completed`, `failed` or `interrupted`. */ + status: string | null + receiverThreadIds: string[] + /** The receivers that are helpers: every one but those the call reports `notFound`. */ + helperThreadIds: string[] + prompt: string | null + /** Each receiver's reported snapshot, in `receiverThreadIds` order: its `CollabAgentStatus` + * (`pendingInit`, `running`, `completed`, `errored`, `shutdown`, …) and its message. */ + states: { threadId: string; status: string | null; message: string | null }[] +} + +function readReceiverThreadIds(item: CodexThreadItem | undefined): string[] { + return Array.isArray(item?.receiverThreadIds) + ? item.receiverThreadIds.filter((id): id is string => typeof id === 'string' && id.length > 0) + : [] +} + +/** `started` is the same call's started item, when it was seen: a call keeps naming the helpers + * it started on even when it ends naming fewer, as a timed-out wait ends naming none. */ +export function readCodexCollabAgentToolCall( + item: CodexThreadItem, + started?: CodexThreadItem +): CodexCollabAgentToolCall | null { + const tool = readString(item, 'tool') + if (item.type !== CODEX_COLLAB_AGENT_TOOL_CALL_ITEM_TYPE || tool === null) { + return null + } + const receiverThreadIds = [ + ...new Set([...readReceiverThreadIds(started), ...readReceiverThreadIds(item)]) + ] + const snapshots = readRecord(item.agentsStates) + const states = receiverThreadIds.flatMap((threadId) => { + if (!Object.hasOwn(snapshots, threadId)) { + return [] + } + const snapshot = readRecord(snapshots[threadId]) + return [ + { threadId, status: readString(snapshot, 'status'), message: readString(snapshot, 'message') } + ] + }) + return { + id: item.id, + tool, + status: readString(item, 'status'), + receiverThreadIds, + helperThreadIds: receiverThreadIds.filter( + (threadId) => readString(readRecord(snapshots[threadId]), 'status') !== 'notFound' + ), + prompt: readString(item, 'prompt'), + states + } +} + +/** A helper's row label: the head of the prompt it was spawned with, on one line. */ +export function codexCollabHelperLabel(prompt: string | null): string | null { + const collapsed = prompt === null ? '' : collapsedToolInputPrefix(prompt) + if (collapsed.length <= MAX_HELPER_LABEL_CHARS) { + return collapsed.length > 0 ? collapsed : null + } + const keep = MAX_HELPER_LABEL_CHARS - 1 + // Never end on half a surrogate pair: the label lands in a durable row. + const last = collapsed.charCodeAt(keep - 1) + const end = last >= 0xd800 && last <= 0xdbff ? keep - 1 : keep + return `${collapsed.slice(0, end)}…` +} + +export function codexCollabToolName(call: CodexCollabAgentToolCall): string { + return codexCollabToolRowName(call.tool) +} diff --git a/src/main/codex/codex-collab-call-delegation.test.ts b/src/main/codex/codex-collab-call-delegation.test.ts new file mode 100644 index 000000000000..96131248ddc6 --- /dev/null +++ b/src/main/codex/codex-collab-call-delegation.test.ts @@ -0,0 +1,114 @@ +// A running chat holds a subagent's section open while the parent's newest row delegates to it. +// In Codex's default multi-agent mode that row is the collab call the host writes as a tool row, +// so the rows the real adapter publishes must read as a delegation to the helper they name. + +import { describe, expect, it } from 'vitest' +import { codexCollabRowAgentIds } from '../../shared/codex-collab-agent-tools' +import { projectNativeChatTranscript } from '../../shared/native-chat-transcript-projection' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { projectStructuredAgentSessionMessages } from '../../shared/structured-agent-session-message-projection' +import { + nativeChatSubagentDelegation, + type NativeChatSubagentDelegation +} from '../../renderer/src/components/native-chat/native-chat-subagent-delegation' +import { + collab, + HELPER, + HELPER_TURN, + PARENT_TURN, + publishedRows, + shell, + spawn, + turn, + waitStarted, + type Frame +} from './codex-collab-call-frames-fixture' +import { THREAD_ID } from './codex-structured-session-adapter-fixture' + +/** The delegation the parent's newest tool run reads as, the row a running chat's frontier judges. */ +async function newestRunDelegation(frames: Frame[]): Promise { + const { conversation } = projectNativeChatTranscript( + projectStructuredAgentSessionMessages(await publishedRows(frames), [], []) + ) + const runs = conversation.filter((message: NativeChatMessage) => + message.blocks.some((block) => block.type === 'tool-call') + ) + const newest = runs.at(-1) + return newest ? nativeChatSubagentDelegation(newest) : null +} + +const spawned = [ + turn('turn/started', THREAD_ID, PARENT_TURN), + spawn('item/started'), + spawn('item/completed'), + turn('turn/started', HELPER, HELPER_TURN), + shell('item/started', HELPER, HELPER_TURN, 'call-helper-shell', 'CHILD_DONE') +] + +describe("a Codex default-mode collab call row as the parent's delegation", () => { + it('delegates to the helper a finished spawn names', async () => { + expect(await newestRunDelegation(spawned)).toEqual({ kind: 'call', agentId: HELPER }) + }) + + it('delegates to the helper a running wait names', async () => { + expect(await newestRunDelegation([...spawned, waitStarted])).toEqual({ + kind: 'call', + agentId: HELPER + }) + }) + + it('delegates to the helper a close names, as a raw collab row did', async () => { + const close = collab('item/started', { + id: 'call-close', + tool: 'closeAgent', + status: 'inProgress', + receiverThreadIds: [HELPER] + }) + expect(await newestRunDelegation([...spawned, close])).toEqual({ + kind: 'call', + agentId: HELPER + }) + }) + + it('reads a spawn still starting, which names no helper, as ordinary output', async () => { + expect( + await newestRunDelegation([ + turn('turn/started', THREAD_ID, PARENT_TURN), + spawn('item/started') + ]) + ).toBeNull() + }) + + it("reads the parent's next call as superseding the delegation", async () => { + expect( + await newestRunDelegation([ + ...spawned, + shell('item/started', THREAD_ID, PARENT_TURN, 'call-parent-shell', 'PARENT_DONE') + ]) + ).toBeNull() + }) + + it('keeps naming the helper when its prompt is past the journal limit', async () => { + const prompt = `Review lane a. ${'x'.repeat(20_000)}` + const longSpawn = [ + turn('turn/started', THREAD_ID, PARENT_TURN), + collab('item/completed', { + id: 'call-spawn', + tool: 'spawnAgent', + status: 'completed', + receiverThreadIds: [HELPER], + prompt + }) + ] + const row = (await publishedRows(longSpawn)) + .map(({ body }) => body) + .find((body) => body.kind === 'tool-call' && body.name === 'spawn_agent') + const input = row?.kind === 'tool-call' ? row.input : undefined + expect(input).toMatchObject({ + description: expect.stringMatching(/^Review lane a\. x+…$/), + prompt: expect.stringContaining('[Orca: output truncated') + }) + expect(codexCollabRowAgentIds(input)).toEqual([HELPER]) + expect(await newestRunDelegation(longSpawn)).toEqual({ kind: 'call', agentId: HELPER }) + }) +}) diff --git a/src/main/codex/codex-collab-call-frames-fixture.ts b/src/main/codex/codex-collab-call-frames-fixture.ts new file mode 100644 index 000000000000..8926e46dcca9 --- /dev/null +++ b/src/main/codex/codex-collab-call-frames-fixture.ts @@ -0,0 +1,127 @@ +// Default-mode collab frames as a live session sent them, and the journal rows the real adapter +// publishes for them: what a client reads. + +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { fakeCodex, identityFor, THREAD_ID } from './codex-structured-session-adapter-fixture' +import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' + +// Shapes as a live default-mode session sent them (codex-cli 0.157); only the sender is remapped. +export const HELPER = '01a0ea72-80bc-7632-a97f-4a9a0d50d08f' +export const PARENT_TURN = 'parent-turn-1' +export const HELPER_TURN = 'helper-turn-1' +export const PROMPT = 'Run exactly one foreground shell command: `sleep 150; echo LATE`.' + +export type Frame = { method: string; params: Record } + +export const turn = ( + method: 'turn/started' | 'turn/completed', + threadId: string, + id: string +): Frame => ({ + method, + params: { threadId, turn: { id, status: 'completed' } } +}) +export const item = ( + method: 'item/started' | 'item/completed', + threadId: string, + turnId: string, + fields: Record +): Frame => ({ method, params: { threadId, turnId, item: fields } }) +export const collab = ( + method: 'item/started' | 'item/completed', + fields: Record +): Frame => + item(method, THREAD_ID, PARENT_TURN, { + type: 'collabAgentToolCall', + senderThreadId: THREAD_ID, + prompt: null, + model: null, + reasoningEffort: null, + agentsStates: {}, + ...fields + }) +export const shell = ( + method: 'item/started' | 'item/completed', + threadId: string, + turnId: string, + id: string, + output: string +): Frame => + item(method, threadId, turnId, { + type: 'commandExecution', + id, + command: `/bin/zsh -lc 'echo ${output}'`, + cwd: '/work/repo', + status: method === 'item/started' ? 'inProgress' : 'completed', + ...(method === 'item/completed' ? { exitCode: 0, aggregatedOutput: `${output}\n` } : {}) + }) + +export const spawn = (method: 'item/started' | 'item/completed'): Frame => + collab(method, { + id: 'call-spawn', + tool: 'spawnAgent', + status: method === 'item/started' ? 'inProgress' : 'completed', + receiverThreadIds: method === 'item/started' ? [] : [HELPER], + prompt: PROMPT, + model: 'gpt-5.5', + reasoningEffort: 'medium', + ...(method === 'item/completed' + ? { agentsStates: { [HELPER]: { status: 'pendingInit', message: null } } } + : {}) + }) +export const waitStarted = collab('item/started', { + id: 'call-wait', + tool: 'wait', + status: 'inProgress', + receiverThreadIds: [HELPER] +}) + +export async function publishedRows(frames: Frame[]): Promise { + const codex = fakeCodex() + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: null + }), + openConnection: codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => 1_700_000_000_500 + }) + // The latest revision of each row, in first-written order, as a client's journal holds it. + const rows = new Map() + const journal: StructuredAgentSessionEventSink = { + appendItem: (identity, body, options) => { + const itemId = JSON.stringify(identity) + const previous = rows.get(itemId) + const sequence = previous?.sequence ?? rows.size + 1 + const { agentId, parentAgentId, producerKind } = options + rows.set(itemId, { + ...previous, + ...(agentId === undefined ? {} : { agentId }), + ...(parentAgentId === undefined ? {} : { parentAgentId }), + ...(producerKind === undefined ? {} : { producerKind }), + itemId, + sequence, + revision: (previous?.revision ?? 0) + 1, + observedAt: sequence, + body + }) + }, + appendTombstone: () => {}, + publish: () => {} + } + await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9', + events: journal + }) + for (const frame of frames) { + codex.connections[0]!.handlers.onNotification?.(frame.method, frame.params) + } + return [...rows.values()] +} diff --git a/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts b/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts new file mode 100644 index 000000000000..795716ed553c --- /dev/null +++ b/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts @@ -0,0 +1,247 @@ +// A client that predates result call ids (an older mobile app, or an older desktop reading a newer +// host) projects the journal itself and pairs each tool result with the oldest unanswered call. +// So every finished collab call row must carry an output, or each later output in its run is drawn +// under the call before its own. + +import { describe, expect, it } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalRenderItem +} from '../../shared/agent-session-journal-types' +import { pairToolBlocks } from '../../shared/native-chat-tool-fold' +import { pairNativeChatToolResults } from '../../shared/native-chat-tool-pairing' +import { projectNativeChatTranscript } from '../../shared/native-chat-transcript-projection' +import type { NativeChatBlock, NativeChatMessage } from '../../shared/native-chat-types' +import { projectStructuredAgentSessionMessages } from '../../shared/structured-agent-session-message-projection' +import { + collab, + HELPER, + HELPER_TURN, + item, + PARENT_TURN, + PROMPT, + publishedRows, + shell, + spawn, + turn, + waitStarted, + type Frame +} from './codex-collab-call-frames-fixture' +import { THREAD_ID } from './codex-structured-session-adapter-fixture' + +type PairedOutput = [call: string, output: string | undefined] + +/** What a client without result call ids draws: each call with the output its pairing gives it. */ +function positionalRuns(rows: AgentJournalRenderItem[]): { + mobile: PairedOutput[][] + desktop: PairedOutput[][] +} { + const withoutCallIds = (message: NativeChatMessage): NativeChatMessage => ({ + ...message, + blocks: message.blocks.map((block): NativeChatBlock => { + if (block.type !== 'tool-result') { + return block + } + const { callId: _callId, ...legacy } = block + return legacy + }) + }) + const transcript = projectNativeChatTranscript( + projectStructuredAgentSessionMessages(rows, [], []).map(withoutCallIds) + ) + // The conversation's runs, then each helper's section's. + const runs = [ + ...transcript.conversation, + ...[...transcript.subagentRows.values()].flat().map((row) => row.message) + ].filter((message) => message.blocks.some((block) => block.type === 'tool-call')) + return { + mobile: runs.map((message) => + pairToolBlocks(message.blocks).map((pair): PairedOutput => [ + pair.call?.name ?? '(none)', + pair.result?.output.trim() + ]) + ), + desktop: runs.map((message) => { + const pairing = pairNativeChatToolResults(message.blocks) + return message.blocks.flatMap((block): PairedOutput[] => + block.type === 'tool-call' + ? [[block.name, pairing.resultByCall.get(block)?.output.trim()]] + : [] + ) + }) + } +} + +const outputOf = (rows: AgentJournalRenderItem[], name: string): AgentJournalItemBody | undefined => + rows.map((row) => row.body).find((body) => body.kind === 'tool-call' && body.name === name) + +describe('Codex collab call rows on a client that pairs results by position', () => { + it('draws each output under its own call through spawn, shell, wait and close', async () => { + const rows = await publishedRows([ + turn('turn/started', THREAD_ID, PARENT_TURN), + spawn('item/started'), + spawn('item/completed'), + turn('turn/started', HELPER, HELPER_TURN), + shell('item/started', THREAD_ID, PARENT_TURN, 'call-parent-shell', 'PARENT_DONE'), + shell('item/completed', THREAD_ID, PARENT_TURN, 'call-parent-shell', 'PARENT_DONE'), + shell('item/started', HELPER, HELPER_TURN, 'call-helper-shell', 'CHILD_DONE'), + shell('item/completed', HELPER, HELPER_TURN, 'call-helper-shell', 'CHILD_DONE'), + item('item/completed', HELPER, HELPER_TURN, { + type: 'agentMessage', + id: 'msg-child', + text: 'CHILD_REPLY' + }), + turn('turn/completed', HELPER, HELPER_TURN), + waitStarted, + collab('item/completed', { + id: 'call-wait', + tool: 'wait', + status: 'completed', + receiverThreadIds: [HELPER], + agentsStates: { [HELPER]: { status: 'completed', message: 'CHILD_REPLY' } } + }), + collab('item/started', { + id: 'call-close', + tool: 'closeAgent', + status: 'inProgress', + receiverThreadIds: [HELPER] + }), + collab('item/completed', { + id: 'call-close', + tool: 'closeAgent', + status: 'completed', + receiverThreadIds: [HELPER], + agentsStates: { [HELPER]: { status: 'completed', message: 'CHILD_REPLY' } } + }), + shell('item/started', THREAD_ID, PARENT_TURN, 'call-parent-shell-2', 'AFTER_CLOSE'), + shell('item/completed', THREAD_ID, PARENT_TURN, 'call-parent-shell-2', 'AFTER_CLOSE') + ]) + // The parent's calls are one run; the helper's own shell is in the helper's section. + const runs = [ + [ + ['spawn_agent', 'Spawned'], + ['shell', 'PARENT_DONE'], + ['wait_agent', 'CHILD_REPLY'], + ['close_agent', 'Closed'], + ['shell', 'AFTER_CLOSE'] + ], + [['shell', 'CHILD_DONE']] + ] + const { mobile, desktop } = positionalRuns(rows) + expect(mobile).toEqual(runs) + expect(desktop).toEqual(runs) + }) + + it('gives a wait that timed out an output, and keeps naming the helpers it waited on', async () => { + const rows = await publishedRows([ + turn('turn/started', THREAD_ID, PARENT_TURN), + spawn('item/completed'), + turn('turn/started', HELPER, HELPER_TURN), + waitStarted, + // Codex ends a timed-out wait naming no receiver and no state. + collab('item/completed', { + id: 'call-wait', + tool: 'wait', + status: 'completed', + receiverThreadIds: [], + agentsStates: {} + }), + collab('item/started', { + id: 'call-close', + tool: 'closeAgent', + status: 'inProgress', + receiverThreadIds: [HELPER] + }), + collab('item/completed', { + id: 'call-close', + tool: 'closeAgent', + status: 'completed', + receiverThreadIds: [HELPER], + agentsStates: { [HELPER]: { status: 'running', message: null } } + }), + shell('item/started', THREAD_ID, PARENT_TURN, 'call-parent-shell', 'PARENT_DONE'), + shell('item/completed', THREAD_ID, PARENT_TURN, 'call-parent-shell', 'PARENT_DONE') + ]) + expect(outputOf(rows, 'wait_agent')).toMatchObject({ + state: 'completed', + input: { description: PROMPT, agents: [HELPER] } + }) + const runs = [ + [ + ['spawn_agent', 'Spawned'], + ['wait_agent', 'Finished waiting'], + // Its snapshot says `running`: the status from before the close. + ['close_agent', 'Closed'], + ['shell', 'PARENT_DONE'] + ] + ] + const { mobile, desktop } = positionalRuns(rows) + expect(mobile).toEqual(runs) + expect(desktop).toEqual(runs) + }) + + it('gives every other finished call what it did or what it reports', async () => { + const OTHER = '01a0ea72-0000-7000-8000-000000000002' + const finished = (id: string, tool: string, fields: Record): Frame[] => [ + collab('item/started', { id, tool, status: 'inProgress', receiverThreadIds: [HELPER] }), + collab('item/completed', { + id, + tool, + status: 'completed', + receiverThreadIds: [HELPER], + ...fields + }) + ] + const rows = await publishedRows([ + turn('turn/started', THREAD_ID, PARENT_TURN), + spawn('item/completed'), + turn('turn/started', HELPER, HELPER_TURN), + // A spawn that created no thread names nothing. + collab('item/completed', { + id: 'call-spawn-failed', + tool: 'spawnAgent', + status: 'failed', + receiverThreadIds: [], + prompt: 'second helper' + }), + ...finished('call-send', 'sendInput', { + prompt: 'keep going', + agentsStates: { [HELPER]: { status: 'running', message: null } } + }), + ...finished('call-resume', 'resumeAgent', { + agentsStates: { [HELPER]: { status: 'completed', message: 'OLD_REPLY' } } + }), + collab('item/started', { + id: 'call-wait', + tool: 'wait', + status: 'inProgress', + receiverThreadIds: [HELPER, OTHER] + }), + // A wait's end names only the helpers that finished; an errored one's message is its error. + collab('item/completed', { + id: 'call-wait', + tool: 'wait', + status: 'failed', + receiverThreadIds: [OTHER], + agentsStates: { [OTHER]: { status: 'errored', message: 'boom' } } + }), + shell('item/started', THREAD_ID, PARENT_TURN, 'call-parent-shell', 'PARENT_DONE'), + shell('item/completed', THREAD_ID, PARENT_TURN, 'call-parent-shell', 'PARENT_DONE') + ]) + const runs = [ + [ + ['spawn_agent', 'Spawned'], + ['spawn_agent', 'Agent spawn failed'], + ['send_input', 'Sent input'], + ['resume_agent', 'Completed - OLD_REPLY'], + ['wait_agent', `${OTHER}: Error - boom`], + ['shell', 'PARENT_DONE'] + ] + ] + const { mobile, desktop } = positionalRuns(rows) + expect(mobile).toEqual(runs) + expect(desktop).toEqual(runs) + // The wait keeps naming both helpers it waited on. + expect(outputOf(rows, 'wait_agent')).toMatchObject({ input: { agents: [HELPER, OTHER] } }) + }) +}) diff --git a/src/main/codex/codex-default-mode-subagents.test.ts b/src/main/codex/codex-default-mode-subagents.test.ts new file mode 100644 index 000000000000..335f903efbbf --- /dev/null +++ b/src/main/codex/codex-default-mode-subagents.test.ts @@ -0,0 +1,704 @@ +// Codex's default multi-agent mode announces a helper only by the `collabAgentToolCall` items that +// spawn, message, wait on or close it. These frames, through the real adapter, must register that +// helper the same way a `subAgentActivity` does: one child in the strip, the host's records and +// the roster row. + +import { describe, expect, it } from 'vitest' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import { createAgentChildWorkAdmission } from '../../shared/agent-status-child-work-admission' +import type { AgentChildWorkRecord } from '../../shared/agent-status-child-work' +import { reconcileAgentChildWorkEvidence } from '../../shared/agent-status-child-work-reconciliation' +import { createAgentStatusStore } from '../../shared/agent-status-store' +import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { fakeCodex, identityFor, THREAD_ID } from './codex-structured-session-adapter-fixture' +import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' +import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import { CodexSubagentExecutions } from './codex-subagent-executions' +import { readCodexSubagentAnnouncements } from './codex-subagent-activity' +import type { CodexThreadItem } from './codex-thread-item-identity' + +const parent = makeStructuredAgentStatusSubject( + { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + 'session-1' +) + +// The spawn and wait items below are verbatim from a live default-mode session (codex-cli +// 0.155.0-alpha.9.2, `multi_agent` on, `multi_agent_v2` off); only the sender is the fixture's +// thread. That session sent no `subAgentActivity` at all. +const HELPER = '01a0d114-2b8e-73e2-a69b-8e86df067ae9' +const PARENT_TURN = '01a0d112-6f86-7092-8c87-b8618f259efa' +const HELPER_TURN = 'helper-turn-1' +const PROMPT = + 'Run the shell command exactly: `sleep 45; echo CHILD_DONE`. After it completes, reply with the single word `CHILD_REPLY` and nothing else.' +/** The prompt's head, as the helper's row names it. */ +const LABEL = 'Run the shell command exactly: `sleep 45; echo CHILD_DONE`. After it completes,…' +const COMMAND = "/bin/zsh -lc 'sleep 45; echo CHILD_DONE'" + +type Frame = { method: string; params: Record } + +const turn = ( + method: 'turn/started' | 'turn/completed', + threadId: string, + id: string, + status = 'completed' +): Frame => ({ method, params: { threadId, turn: { id, status } } }) +const item = ( + method: 'item/started' | 'item/completed', + threadId: string, + turnId: string, + fields: Record +): Frame => ({ method, params: { threadId, turnId, item: fields } }) +const collab = ( + method: 'item/started' | 'item/completed', + fields: Record +): Frame => + item(method, THREAD_ID, PARENT_TURN, { + type: 'collabAgentToolCall', + senderThreadId: THREAD_ID, + prompt: null, + model: null, + reasoningEffort: null, + agentsStates: {}, + ...fields + }) + +const spawnStarted = collab('item/started', { + id: 'call_dxoSnQY1cHVswlN8MGFJBxHt', + tool: 'spawnAgent', + status: 'inProgress', + receiverThreadIds: [], + prompt: PROMPT, + model: '', + reasoningEffort: 'medium' +}) +const spawnCompleted = collab('item/completed', { + id: 'call_dxoSnQY1cHVswlN8MGFJBxHt', + tool: 'spawnAgent', + status: 'completed', + receiverThreadIds: [HELPER], + prompt: PROMPT, + model: 'gpt-5.5', + reasoningEffort: 'medium', + agentsStates: { [HELPER]: { status: 'pendingInit', message: null } } +}) +const waitStarted = collab('item/started', { + id: 'call_rMn9MIAPavyhOD6ifjH38A8E', + tool: 'wait', + status: 'inProgress', + receiverThreadIds: [HELPER] +}) +const waitCompleted = collab('item/completed', { + id: 'call_rMn9MIAPavyhOD6ifjH38A8E', + tool: 'wait', + status: 'completed', + receiverThreadIds: [HELPER], + agentsStates: { [HELPER]: { status: 'completed', message: 'CHILD_REPLY' } } +}) +const closeCompleted = collab('item/completed', { + id: 'call-close', + tool: 'closeAgent', + status: 'completed', + receiverThreadIds: [HELPER], + agentsStates: { [HELPER]: { status: 'shutdown', message: null } } +}) +const helperShell = (method: 'item/started' | 'item/completed'): Frame => + item(method, HELPER, HELPER_TURN, { + type: 'commandExecution', + id: 'call_3iUPkSEtwkUYstngZm5CNJ52', + command: COMMAND, + cwd: '/work/repo', + // As the live session reported it; a command is tracked the same whatever its source. + source: 'unifiedExecStartup', + status: method === 'item/started' ? 'inProgress' : 'completed', + ...(method === 'item/completed' ? { exitCode: 0, aggregatedOutput: 'CHILD_DONE\n' } : {}) + }) +const helperReply = item('item/completed', HELPER, HELPER_TURN, { + type: 'agentMessage', + id: 'msg-child', + text: 'CHILD_REPLY' +}) +const activityStarted: Frame = item('item/started', THREAD_ID, PARENT_TURN, { + type: 'subAgentActivity', + id: 'activity-helper', + kind: 'started', + agentThreadId: HELPER, + agentPath: '/root/sleeper' +}) + +async function session() { + const codex = fakeCodex() + const store = createAgentStatusStore({ epoch: 'epoch-1', mode: 'authority' }) + expect(store.applyMutation({ parent: { subject: parent } })).not.toBeNull() + let minted = 0 + const admission = createAgentChildWorkAdmission(store, { + mintChildWorkId: () => `child-${++minted}` + }) + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: null + }), + openConnection: codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => 1_700_000_000_500, + onChildWorkEvidence: (_sessionId, evidence) => + reconcileAgentChildWorkEvidence({ store, admission, parent, provider: 'codex', evidence }) + }) + // The latest revision of each journal row, in first-written order. + const rows = new Map() + const journal: StructuredAgentSessionEventSink = { + appendItem: (identity, body) => rows.set(JSON.stringify(identity), body), + appendTombstone: () => {}, + publish: () => {} + } + await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9', + events: journal + }) + const send = (...frames: Frame[]): void => { + for (const frame of frames) { + codex.connections[0]!.handlers.onNotification?.(frame.method, frame.params) + } + } + const agents = (): AgentChildWorkRecord[] => + store.getChildren(parent).filter((record) => record.kind === 'agent') + const commands = (): AgentChildWorkRecord[] => + store.getChildren(parent).filter((record) => record.kind === 'command') + const strip = () => adapter.backgroundTaskState('session-1')?.tasks ?? [] + const toolRow = (name: string) => + [...rows.values()].find((body) => body.kind === 'tool-call' && body.name === name) + const rosterRows = () => + [...rows.values()].flatMap((body) => + body.kind === 'message' + ? body.blocks.flatMap((block) => (block.type === 'subagent-group' ? [block] : [])) + : [] + ) + const rawCollabRows = () => + [...rows.values()].filter( + (body) => body.kind === 'status' && body.providerFrame?.kind === 'item:collabAgentToolCall' + ) + return { send, agents, commands, strip, toolRow, rosterRows, rawCollabRows } +} + +describe('Codex default-mode helpers', () => { + it('registers a helper announced only by its spawn call, in the strip, the records and the roster row', async () => { + const run = await session() + run.send(turn('turn/started', THREAD_ID, PARENT_TURN), spawnStarted) + // The call has not said which thread the helper is yet. + expect(run.agents()).toEqual([]) + expect(run.strip()).toEqual([]) + + run.send(spawnCompleted, turn('turn/started', HELPER, HELPER_TURN), waitStarted) + expect(run.agents()).toEqual([ + expect.objectContaining({ + membership: 'live', + state: 'working', + description: LABEL, + invocation: { invocationId: HELPER_TURN, generation: 1 } + }) + ]) + expect(run.strip()).toEqual([ + { id: `codex-agent:${HELPER}`, kind: 'agent', description: LABEL } + ]) + expect(run.rosterRows()).toEqual([ + expect.objectContaining({ + agents: [expect.objectContaining({ id: HELPER, label: LABEL, state: 'working' })] + }) + ]) + + // While the helper runs, its command is the helper's work, not the session's own. + run.send(helperShell('item/started')) + expect(run.strip()).toEqual([ + { id: `codex-agent:${HELPER}`, kind: 'agent', description: LABEL } + ]) + expect(run.commands()).toEqual([ + expect.objectContaining({ + membership: 'live', + description: COMMAND, + parentChildWorkId: run.agents()[0]?.childWorkId + }) + ]) + expect(run.agents()[0]?.operation).toMatchObject({ toolName: 'Bash', input: COMMAND }) + + run.send(helperShell('item/completed')) + // A finished command leaves no record behind. + expect(run.commands()).toEqual([]) + + run.send(helperReply, turn('turn/completed', HELPER, HELPER_TURN)) + expect(run.agents()).toEqual([ + expect.objectContaining({ + membership: 'settled', + outcome: 'succeeded', + lastMessage: 'CHILD_REPLY' + }) + ]) + expect(run.strip()).toEqual([]) + expect(run.rosterRows().at(-1)?.agents).toEqual([ + expect.objectContaining({ id: HELPER, state: 'completed' }) + ]) + }) + + it('labels a command the helper leaves running with the helper, once its turn is over', async () => { + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + spawnCompleted, + turn('turn/started', HELPER, HELPER_TURN), + helperShell('item/started'), + turn('turn/completed', HELPER, HELPER_TURN) + ) + expect(run.strip()).toEqual([ + expect.objectContaining({ kind: 'command', description: `${LABEL} — ${COMMAND}` }) + ]) + }) + + it('renders each collab call as a tool row naming its helper, never as a raw provider row', async () => { + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + spawnStarted, + spawnCompleted, + turn('turn/started', HELPER, HELPER_TURN), + waitStarted + ) + expect(run.toolRow('wait_agent')).toMatchObject({ + state: 'running', + input: { description: LABEL, agents: [HELPER] } + }) + run.send(turn('turn/completed', HELPER, HELPER_TURN), waitCompleted) + expect(run.toolRow('spawn_agent')).toMatchObject({ + state: 'completed', + input: { description: LABEL, prompt: PROMPT, model: 'gpt-5.5', agents: [HELPER] } + }) + expect(run.toolRow('wait_agent')).toMatchObject({ + state: 'completed', + input: { description: LABEL }, + output: expect.objectContaining({ head: 'CHILD_REPLY' }) + }) + expect(run.rawCollabRows()).toEqual([]) + }) + + it('keeps naming the helper when the turn ends with its wait still open', async () => { + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + spawnCompleted, + turn('turn/started', HELPER, HELPER_TURN), + waitStarted, + turn('turn/completed', THREAD_ID, PARENT_TURN, 'interrupted') + ) + expect(run.toolRow('wait_agent')).toMatchObject({ + state: 'failed', + input: { description: LABEL } + }) + }) + + it('registers the helper whichever order its first turn and its spawn arrive in', async () => { + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + turn('turn/started', HELPER, HELPER_TURN), + spawnCompleted + ) + expect(run.agents()).toEqual([ + expect.objectContaining({ membership: 'live', state: 'working', description: LABEL }) + ]) + expect(run.strip()).toHaveLength(1) + expect(run.rosterRows()).toEqual([ + expect.objectContaining({ + agents: [expect.objectContaining({ id: HELPER, label: LABEL, state: 'working' })] + }) + ]) + // The roster row does not stand in for the call's own row. + expect(run.toolRow('spawn_agent')).toMatchObject({ + state: 'completed', + input: { description: LABEL } + }) + }) + + it('registers a helper whose spawn call failed but created its thread, as Codex reports an errored one', async () => { + const run = await session() + // The shape Codex builds for a helper that errored at birth: the call fails, yet names the + // thread it created, and that thread can still run. + const spawnFailedWithThread = collab('item/completed', { + id: 'call-failed-spawn', + tool: 'spawnAgent', + status: 'failed', + receiverThreadIds: [HELPER], + prompt: PROMPT, + model: 'gpt-5.5', + reasoningEffort: 'medium', + agentsStates: { [HELPER]: { status: 'errored', message: null } } + }) + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + spawnFailedWithThread, + turn('turn/started', HELPER, HELPER_TURN), + helperShell('item/started') + ) + expect(run.agents()).toEqual([ + expect.objectContaining({ membership: 'live', state: 'working', description: LABEL }) + ]) + // Its command is the helper's, not a bare command of the session's own. + expect(run.strip()).toEqual([ + { id: `codex-agent:${HELPER}`, kind: 'agent', description: LABEL } + ]) + expect(run.rosterRows()).toEqual([ + expect.objectContaining({ + agents: [expect.objectContaining({ id: HELPER, state: 'working' })] + }) + ]) + }) + + it('registers nothing for a spawn that created no thread', async () => { + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + collab('item/completed', { + id: 'call-refused-spawn', + tool: 'spawnAgent', + status: 'failed', + receiverThreadIds: [], + prompt: PROMPT + }) + ) + expect(run.agents()).toEqual([]) + expect(run.strip()).toEqual([]) + expect(run.rosterRows()).toEqual([]) + }) + + it('leaves a helper running when its caller failed to close it', async () => { + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + spawnCompleted, + turn('turn/started', HELPER, HELPER_TURN), + collab('item/completed', { + id: 'call-close-failed', + tool: 'closeAgent', + status: 'failed', + receiverThreadIds: [HELPER], + agentsStates: { [HELPER]: { status: 'notFound', message: null } } + }) + ) + expect(run.agents()).toEqual([expect.objectContaining({ membership: 'live' })]) + expect(run.strip()).toHaveLength(1) + }) + + it("ends a closed helper only on its own interrupted turn, never on the caller's close item", async () => { + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + spawnCompleted, + turn('turn/started', HELPER, HELPER_TURN), + // What Codex reports for a close that failed on a running helper: the status is the + // helper's, not the close's, so `completed` says nothing about whether it stopped. + collab('item/completed', { + id: 'call-close-errored', + tool: 'closeAgent', + status: 'completed', + receiverThreadIds: [HELPER], + agentsStates: { [HELPER]: { status: 'running', message: null } } + }) + ) + expect(run.agents()).toEqual([expect.objectContaining({ membership: 'live' })]) + expect(run.strip()).toHaveLength(1) + expect(run.rosterRows().at(-1)?.agents).toEqual([ + expect.objectContaining({ id: HELPER, state: 'working' }) + ]) + + // A close that works aborts the helper's turn, which Codex reports on the helper's thread. + run.send(closeCompleted, turn('turn/completed', HELPER, HELPER_TURN, 'interrupted')) + expect(run.agents()).toEqual([ + expect.objectContaining({ membership: 'settled', outcome: 'cancelled' }) + ]) + expect(run.strip()).toEqual([]) + expect(run.rosterRows().at(-1)?.agents).toEqual([ + expect.objectContaining({ id: HELPER, state: 'stopped' }) + ]) + expect(run.toolRow('close_agent')).toMatchObject({ + state: 'completed', + input: { description: LABEL } + }) + }) + + it("keeps a closed helper's running command until Codex reports the killed process's exit", async () => { + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + spawnCompleted, + turn('turn/started', HELPER, HELPER_TURN), + helperShell('item/started'), + turn('turn/completed', HELPER, HELPER_TURN), + closeCompleted + ) + // The close shuts the helper's thread down and kills its processes; each still reports its + // exit on the helper's thread, and that exit is what ends the command. + expect(run.commands()).toEqual([expect.objectContaining({ membership: 'live' })]) + run.send(helperShell('item/completed')) + expect(run.commands()).toEqual([]) + expect(run.strip()).toEqual([]) + }) + + it('leaves a finished helper finished when its caller closes it', async () => { + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + spawnCompleted, + turn('turn/started', HELPER, HELPER_TURN), + turn('turn/completed', HELPER, HELPER_TURN), + closeCompleted + ) + expect(run.agents()).toEqual([ + expect.objectContaining({ membership: 'settled', outcome: 'succeeded' }) + ]) + }) + + it('keeps one child for a session that announces the helper both ways', async () => { + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + activityStarted, + spawnCompleted, + turn('turn/started', HELPER, HELPER_TURN), + { ...activityStarted, method: 'item/completed' } + ) + // The activity landed first, so its task name labels the one child. + expect(run.agents()).toEqual([expect.objectContaining({ description: 'sleeper' })]) + expect(run.strip()).toEqual([ + { id: `codex-agent:${HELPER}`, kind: 'agent', description: 'sleeper' } + ]) + expect(run.rosterRows().at(-1)?.agents).toEqual([ + expect.objectContaining({ id: HELPER, label: 'sleeper' }) + ]) + }) +}) + +describe('a helper whose spawn was never seen', () => { + const sendInput = collab('item/completed', { + id: 'call-send-input', + tool: 'sendInput', + status: 'completed', + receiverThreadIds: [HELPER], + prompt: 'Now run it again.', + agentsStates: { [HELPER]: { status: 'running', message: null } } + }) + + it("registers from any call that names it, so its shell is no longer the session's own", async () => { + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + turn('turn/started', HELPER, HELPER_TURN), + helperShell('item/started') + ) + expect(run.strip()).toEqual([expect.objectContaining({ kind: 'command' })]) + + run.send(sendInput) + // No spawn named it, so it reads as any unnamed subagent does. + expect(run.strip()).toEqual([{ id: `codex-agent:${HELPER}`, kind: 'agent' }]) + expect(run.agents()).toEqual([ + expect.objectContaining({ membership: 'live', state: 'working' }) + ]) + expect(run.commands()).toEqual([ + expect.objectContaining({ + membership: 'live', + parentChildWorkId: run.agents()[0]?.childWorkId + }) + ]) + expect(run.rosterRows().at(-1)?.agents).toEqual([ + expect.objectContaining({ id: HELPER, label: 'subagent', state: 'working' }) + ]) + expect(run.toolRow('send_input')).toMatchObject({ input: { description: HELPER } }) + }) + + it('registers each helper a call on several names', async () => { + const second = 'helper-two' + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + turn('turn/started', HELPER, HELPER_TURN), + turn('turn/started', second, 'helper-two-turn'), + collab('item/started', { + id: 'call-wait-both', + tool: 'wait', + status: 'inProgress', + receiverThreadIds: [HELPER, second] + }) + ) + expect(run.strip()).toEqual([ + { id: `codex-agent:${HELPER}`, kind: 'agent' }, + { id: `codex-agent:${second}`, kind: 'agent' } + ]) + expect(run.agents()).toEqual([ + expect.objectContaining({ membership: 'live' }), + expect.objectContaining({ membership: 'live' }) + ]) + expect(run.rosterRows().at(-1)?.agents).toEqual([ + expect.objectContaining({ id: HELPER, label: 'subagent' }), + expect.objectContaining({ id: second, label: 'subagent 2' }) + ]) + }) + + it("puts a helper's run in the turn that sent it work, even after that turn ended", async () => { + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + spawnCompleted, + turn('turn/started', HELPER, HELPER_TURN), + turn('turn/completed', HELPER, HELPER_TURN), + turn('turn/completed', THREAD_ID, PARENT_TURN), + turn('turn/started', THREAD_ID, 'parent-turn-2'), + { ...sendInput, params: { ...sendInput.params, turnId: 'parent-turn-2' } }, + turn('turn/completed', THREAD_ID, 'parent-turn-2'), + turn('turn/started', HELPER, 'helper-turn-2') + ) + const groups = new Set(run.rosterRows().map((row) => row.groupId)) + expect(groups.size).toBe(2) + expect(run.rosterRows().at(-1)?.agents).toEqual([ + expect.objectContaining({ id: HELPER, state: 'working' }) + ]) + }) + + it('registers every helper a call names, and none it reports not found', () => { + const [second, missing] = ['helper-two', 'helper-missing'] + const waited: CodexThreadItem = { + type: 'collabAgentToolCall', + id: 'call-wait-many', + tool: 'wait', + status: 'completed', + receiverThreadIds: [HELPER, second, missing], + agentsStates: { + [HELPER]: { status: 'completed', message: 'done' }, + [second]: { status: 'running', message: null }, + [missing]: { status: 'notFound', message: null } + } + } + expect(readCodexSubagentAnnouncements(waited, THREAD_ID)).toEqual([ + { agentThreadId: HELPER, label: null, namesParentTurn: false, spawned: false }, + { agentThreadId: second, label: null, namesParentTurn: false, spawned: false } + ]) + }) +}) + +describe('the roster row follows a helper whose turn ends', () => { + const lastRow = (run: Awaited>) => run.rosterRows().at(-1)?.agents + const running = async () => { + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + activityStarted, + turn('turn/started', HELPER, HELPER_TURN) + ) + expect(lastRow(run)).toEqual([expect.objectContaining({ id: HELPER, state: 'working' })]) + return run + } + + it('settles the row with the strip and the record on the failed completion that follows a fatal error', async () => { + const run = await running() + // Codex sends the fatal error, then the helper's failed `turn/completed` 0-32 ms later. + run.send({ + method: 'error', + params: { + threadId: HELPER, + turnId: HELPER_TURN, + willRetry: false, + error: { message: 'Selected model is at capacity.', codexErrorInfo: 'serverOverloaded' } + } + }) + expect(run.strip()).toEqual([expect.objectContaining({ id: `codex-agent:${HELPER}` })]) + expect(run.agents()).toEqual([expect.objectContaining({ membership: 'live' })]) + expect(lastRow(run)).toEqual([expect.objectContaining({ id: HELPER, state: 'working' })]) + + run.send(turn('turn/completed', HELPER, HELPER_TURN, 'failed')) + expect(run.strip()).toEqual([]) + expect(run.agents()).toEqual([ + expect.objectContaining({ membership: 'settled', outcome: 'failed' }) + ]) + expect(lastRow(run)).toEqual([expect.objectContaining({ id: HELPER, state: 'failed' })]) + }) + + it('settles the row with the strip and the record when its thread closes with no turn/completed', async () => { + const run = await running() + run.send({ method: 'thread/closed', params: { threadId: HELPER } }) + expect(run.strip()).toEqual([]) + expect(run.agents()).toEqual([ + expect.objectContaining({ membership: 'settled', outcome: 'unknown' }) + ]) + expect(lastRow(run)).toEqual([expect.objectContaining({ id: HELPER, state: 'unverifiable' })]) + }) +}) + +describe('a restored thread', () => { + function restored(replayed = [spawnCompleted, waitCompleted]) { + const executions = new CodexSubagentExecutions() + const rows = new Map() + const translator = createCodexJournalTranslator({ + sink: { + appendItem: (identity, body) => rows.set(JSON.stringify(identity), body), + appendTombstone: () => {}, + publish: () => {} + }, + primaryThreadId: () => THREAD_ID, + subagentExecutions: executions, + schedule: (run: () => void) => { + run() + return () => {} + } + }) + const tracker = new CodexBackgroundTaskTracker(THREAD_ID, executions) + const admission = translator.restoreThread(THREAD_ID, { + turns: [{ id: PARENT_TURN, items: replayed.map((frame) => frame.params.item) }] + }) + expect(admission).toEqual({ accepted: true }) + const bodies = () => [...rows.values()] + return { executions, tracker, bodies } + } + + it('names the helper on a replayed call row, as the live row did', () => { + const { bodies } = restored() + expect(bodies()).toContainEqual( + expect.objectContaining({ + kind: 'tool-call', + name: 'wait_agent', + input: expect.objectContaining({ description: LABEL }) + }) + ) + }) + + it('claims no running helper for a name it learned from history', () => { + const { executions, tracker, bodies } = restored() + expect(executions.workingChildren()).toEqual([]) + // A live frame republishes the strip and drains evidence: neither holds the helper. + tracker.observe({ + method: 'turn/started', + threadId: THREAD_ID, + params: { threadId: THREAD_ID, turn: { id: 'next-turn', status: 'inProgress' } } + }) + expect(tracker.state).toBeNull() + expect(tracker.drainChildWorkEvidence(1)).toEqual([]) + expect( + bodies().filter( + (body) => + body.kind === 'message' && body.blocks.some((block) => block.type === 'subagent-group') + ) + ).toEqual([]) + }) + + it('knows a helper whose spawn history compacted away, from a call on it', () => { + const { tracker } = restored([waitCompleted]) + tracker.observe({ ...turn('turn/started', HELPER, HELPER_TURN), threadId: HELPER }) + expect(tracker.state?.tasks).toEqual([{ id: `codex-agent:${HELPER}`, kind: 'agent' }]) + }) +}) diff --git a/src/main/codex/codex-hook-command-form.test.ts b/src/main/codex/codex-hook-command-form.test.ts new file mode 100644 index 000000000000..ed16b136573a --- /dev/null +++ b/src/main/codex/codex-hook-command-form.test.ts @@ -0,0 +1,271 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { + buildWindowsHookPowerShellCommand, + createManagedCommandMatcher, + wrapWindowsHookCommand, + type HookDefinition +} from '../agent-hooks/installer-utils' +import { + buildCodexHookCommand, + CODEX_HOOK_COMMAND_FORM, + readCodexHookCommandForm +} from './codex-hook-command-form' +import { getCodexManagedHookInstallMaterial } from './codex-hook-definition' +import { planRealHomeCodexHookEntries } from './codex-real-home-hook-entry-plan' + +// Why goldens: these bytes are shared by every Orca on a HOME. Changing them +// without a form bump makes builds rewrite each other's entry again. +const POSIX_GOLDEN = + ': orca-agent-hook-form=1; if [ -n "${ORCA_PANE_KEY-}" ] && [ -n "${ORCA_AGENT_HOOK_ROOT-}" ] && [ -f "${ORCA_AGENT_HOOK_ROOT-}/agent-hooks/codex-hook.sh" ]; then /bin/sh "${ORCA_AGENT_HOOK_ROOT-}/agent-hooks/codex-hook.sh" || :; elif [ -z "${ORCA_AGENT_HOOK_ROOT-}" ] && [ -n "${ORCA_PANE_KEY-}" ] && [ -n "${ORCA_AGENT_HOOK_PORT-}" ] && [ -f "${HOME-}/.orca/agent-hooks/codex-hook.sh" ]; then /bin/sh "${HOME-}/.orca/agent-hooks/codex-hook.sh" || :; else { command -p cat 2>/dev/null || cat; } >/dev/null 2>&1 || :; fi' +const WINDOWS_BARE_GOLDEN = 'C:/Users/alice/.orca/agent-hooks/codex-hook.cmd' +const WINDOWS_CMD_GOLDEN = + 'C:\\Windows\\System32\\cmd.exe --% /d /v:off /c @"C:/Users/First Last/.orca/agent-hooks/codex-hook.cmd"' +// Why kept: local builds before the absolute cmd.exe wrote it; the managed installer and app start convert it. +const WINDOWS_BARE_CMD_SPELLING = + 'cmd --% /d /c @"C:/Users/First Last/.orca/agent-hooks/codex-hook.cmd"' +const WINDOWS_ENV = { SystemRoot: 'C:\\Windows', ComSpec: 'C:\\Windows\\system32\\cmd.exe' } +// Why kept: dev builds of this form wrote it for a spaced profile path; app start converts it. +const WINDOWS_POWERSHELL_TEXT = + "<# orca-agent-hook-form=1 #> if ($env:ORCA_PANE_KEY -and $env:ORCA_AGENT_HOOK_ROOT -and (Test-Path -LiteralPath (Join-Path $env:ORCA_AGENT_HOOK_ROOT 'agent-hooks\\codex-hook.cmd') -PathType Leaf)) { & (Join-Path $env:ORCA_AGENT_HOOK_ROOT 'agent-hooks\\codex-hook.cmd') } elseif (-not $env:ORCA_AGENT_HOOK_ROOT -and $env:ORCA_PANE_KEY -and $env:ORCA_AGENT_HOOK_PORT -and (Test-Path -LiteralPath 'C:/Users/First Last/.orca/agent-hooks/codex-hook.cmd' -PathType Leaf)) { & 'C:/Users/First Last/.orca/agent-hooks/codex-hook.cmd' } else { if (-not $env:ORCA_AGENT_HOOK_PORT -or -not $env:ORCA_AGENT_HOOK_TOKEN -or -not $env:ORCA_PANE_KEY) { exit 0 }; [Console]::In.ReadToEnd() | Out-Null }; exit 0" + +function windowsCommandFor( + profileDirName: string, + env: Record = WINDOWS_ENV +): string { + return buildCodexHookCommand( + `C:\\Users\\${profileDirName}\\.orca\\agent-hooks\\codex-hook.cmd`, + 'win32', + env + ) +} + +describe('frozen Codex hook command', () => { + it('matches the form 1 goldens', () => { + expect(CODEX_HOOK_COMMAND_FORM).toBe(1) + expect(buildCodexHookCommand('/home/a/.orca/agent-hooks/codex-hook.sh', 'linux')).toBe( + POSIX_GOLDEN + ) + expect(windowsCommandFor('alice')).toBe(WINDOWS_BARE_GOLDEN) + expect(windowsCommandFor('First Last')).toBe(WINDOWS_CMD_GOLDEN) + }) + + it.each([' ', '&', '^', '$', '`', "'", '!', '(', ')', 'é', '测'])( + 'takes the cmd spelling for a profile path holding %j', + (character) => { + const name = `a${character}b` + expect(windowsCommandFor(name)).toBe( + `C:\\Windows\\System32\\cmd.exe --% /d /v:off /c @"C:/Users/${name}/.orca/agent-hooks/codex-hook.cmd"` + ) + } + ) + + it.each(['alice', 'Alice.Smith-2', 'a_b', 'ALICE~1'])( + 'keeps the bare path for the safe profile path %s', + (name) => { + expect(windowsCommandFor(name)).toBe(`C:/Users/${name}/.orca/agent-hooks/codex-hook.cmd`) + } + ) + + it('writes the same Windows bytes for a path in either slash direction', () => { + for (const name of ['alice', 'First Last']) { + const backslashed = `C:\\Users\\${name}\\.orca\\agent-hooks\\codex-hook.cmd` + expect(buildCodexHookCommand(backslashed, 'win32', WINDOWS_ENV)).toBe( + buildCodexHookCommand(backslashed.replaceAll('\\', '/'), 'win32', WINDOWS_ENV) + ) + } + }) + + it('names the system cmd.exe from %SystemRoot%, on any drive, with backslashes', () => { + expect(windowsCommandFor('First Last', { SystemRoot: 'D:\\Windows' })).toBe( + 'D:\\Windows\\System32\\cmd.exe --% /d /v:off /c @"C:/Users/First Last/.orca/agent-hooks/codex-hook.cmd"' + ) + expect(windowsCommandFor('First Last', { SystemRoot: 'C:\\WINDOWS\\' })).toBe( + WINDOWS_CMD_GOLDEN.replace('C:\\Windows\\', 'C:\\WINDOWS\\') + ) + // Why: a safe profile path never names cmd.exe, whatever the Windows directory. + expect(windowsCommandFor('alice', { SystemRoot: 'D:\\Windows' })).toBe(WINDOWS_BARE_GOLDEN) + }) + + it('falls back to the Windows directory holding %ComSpec% when %SystemRoot% is unset', () => { + expect(windowsCommandFor('First Last', { ComSpec: 'E:\\WinNT\\system32\\cmd.exe' })).toBe( + 'E:\\WinNT\\System32\\cmd.exe --% /d /v:off /c @"C:/Users/First Last/.orca/agent-hooks/codex-hook.cmd"' + ) + // Why: both sources name the same directory on a machine, so a process missing one writes the same bytes. + expect(windowsCommandFor('First Last', { ComSpec: 'C:\\Windows\\system32\\cmd.exe' })).toBe( + WINDOWS_CMD_GOLDEN + ) + }) + + it.each([ + ['unset', {}], + ['relative', { SystemRoot: 'Windows' }], + ['spaced', { SystemRoot: 'C:\\My Windows' }], + ['holding &', { SystemRoot: 'C:\\Win&dows' }], + ['a UNC share', { SystemRoot: '\\\\server\\Windows' }], + ['another shell in %ComSpec%', { ComSpec: 'C:\\Tools\\tcc.exe' }], + ['a spaced %ComSpec%', { ComSpec: 'C:\\My Windows\\System32\\cmd.exe' }] + ])( + 'names C:\\Windows\\System32\\cmd.exe, never a bare or quoted cmd, when the Windows directory is %s', + (_case, env) => { + expect(windowsCommandFor('First Last', env)).toBe(WINDOWS_CMD_GOLDEN) + } + ) + + it('writes identical bytes for the same profile path and Windows directory', () => { + const first = windowsCommandFor('First Last', { SystemRoot: 'D:\\Windows' }) + expect(windowsCommandFor('First Last', { SystemRoot: 'D:\\Windows' })).toBe(first) + expect(windowsCommandFor('First Last', { SystemRoot: 'D:/Windows/' })).toBe(first) + }) + + it('writes identical POSIX bytes whatever the home or build', () => { + expect(buildCodexHookCommand('/Users/a/.orca/agent-hooks/codex-hook.sh', 'darwin')).toBe( + buildCodexHookCommand('/home/b/.orca/agent-hooks/codex-hook.sh', 'linux') + ) + }) + + it.each([POSIX_GOLDEN, WINDOWS_BARE_GOLDEN, WINDOWS_CMD_GOLDEN])( + 'keeps the script name in plain text so every older build still recognizes it', + (command) => { + const isOrca = createManagedCommandMatcher( + command === POSIX_GOLDEN ? 'codex-hook.sh' : 'codex-hook.cmd' + ) + expect(isOrca(command)).toBe(true) + } + ) + + it('reads the form: current, higher, and unmarked older forms', () => { + expect(readCodexHookCommandForm(POSIX_GOLDEN, POSIX_GOLDEN)).toBe(1) + expect(readCodexHookCommandForm(WINDOWS_BARE_GOLDEN, WINDOWS_BARE_GOLDEN)).toBe(1) + expect(readCodexHookCommandForm(POSIX_GOLDEN.replace('form=1', 'form=2'), POSIX_GOLDEN)).toBe(2) + expect( + readCodexHookCommandForm( + "if [ -f '/h/.orca/agent-hooks/codex-hook.sh' ]; then /bin/sh '/h/.orca/agent-hooks/codex-hook.sh'; fi", + POSIX_GOLDEN + ) + ).toBe(0) + expect( + readCodexHookCommandForm( + 'C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd', + WINDOWS_BARE_GOLDEN + ) + ).toBe(0) + }) +}) + +describe('the Windows spelling change', () => { + const hooksWith = (command: string): Record => ({ + Stop: [ + { hooks: [{ type: 'command', command, timeout: 10 }] }, + { hooks: [{ type: 'command', command: 'user-hook.cmd' }] } + ] + }) + const plan = (command: string, policy: 'add-missing-only' | 'convert-older-forms') => + planRealHomeCodexHookEntries({ + hooks: hooksWith(command), + sourcePath: 'C:/Users/First Last/.codex/hooks.json', + material: { + ...getCodexManagedHookInstallMaterial(), + events: ['Stop'], + command: WINDOWS_CMD_GOLDEN + }, + isOrcaCommand: createManagedCommandMatcher('codex-hook.cmd'), + policy + }) + + it.each([ + ['the PowerShell-text form', WINDOWS_POWERSHELL_TEXT], + ['the bare-cmd spelling', WINDOWS_BARE_CMD_SPELLING], + [ + "main's PowerShell text", + buildWindowsHookPowerShellCommand('C:\\Users\\First Last\\.orca\\agent-hooks\\codex-hook.cmd') + ], + [ + 'the encoded launcher', + wrapWindowsHookCommand('C:\\Users\\First Last\\.orca\\agent-hooks\\codex-hook.cmd') + ] + ])('converts %s to the cmd.exe spelling once, in its slot, at app start', (_case, older) => { + const converted = plan(older, 'convert-older-forms') + expect(converted.changed).toBe(true) + expect(converted.hooks.Stop).toEqual([ + { hooks: [{ type: 'command', command: WINDOWS_CMD_GOLDEN, timeout: 10 }] }, + { hooks: [{ type: 'command', command: 'user-hook.cmd' }] } + ]) + expect(plan(WINDOWS_CMD_GOLDEN, 'convert-older-forms').changed).toBe(false) + }) + + it.each([WINDOWS_POWERSHELL_TEXT, WINDOWS_BARE_CMD_SPELLING])( + 'leaves the older Windows form %# alone on a pane launch', + (older) => { + expect(plan(older, 'add-missing-only').changed).toBe(false) + } + ) +}) + +describe.skipIf(process.platform === 'win32')('frozen Codex hook command under /bin/sh', () => { + const roots: string[] = [] + afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + }) + + function makeScripts(): { home: string; root: string; marker: string } { + const dir = mkdtempSync(join(tmpdir(), 'orca-codex-hook-form-')) + roots.push(dir) + const home = join(dir, 'home') + const root = join(dir, 'root') + const marker = join(dir, 'ran') + for (const [base, label] of [ + [join(home, '.orca'), 'shared'], + [root, 'root'] + ]) { + mkdirSync(join(base, 'agent-hooks'), { recursive: true }) + writeFileSync( + join(base, 'agent-hooks', 'codex-hook.sh'), + `cat >/dev/null; printf ${label} >> '${marker}'; exit 2\n` + ) + } + return { home, root, marker } + } + + async function run(env: Record): Promise<{ code: number | null; ran: string }> { + const result = await runProcess({ + program: '/bin/sh', + args: ['-c', POSIX_GOLDEN], + input: '{"hook_event_name":"Stop"}', + env: { PATH: process.env.PATH ?? '', ...env }, + timeoutMs: 10_000 + }) + const ran = existsSync(env.MARKER) ? readFileSync(env.MARKER, 'utf-8') : '' + return { code: result.code, ran } + } + + it.each([ + ['outside Orca', {}, ''], + ['a pane with hooks off', { ORCA_PANE_KEY: 'tab:leaf' }, ''], + ['a pane with hooks on', { ORCA_PANE_KEY: 'tab:leaf', ORCA_AGENT_HOOK_PORT: '1' }, 'shared'], + [ + 'a structured child with a root', + { ORCA_AGENT_HOOK_PORT: '1', ORCA_AGENT_HOOK_ROOT: 'R' }, + '' + ], + [ + 'a pane with a hook root', + { ORCA_PANE_KEY: 'tab:leaf', ORCA_AGENT_HOOK_PORT: '1', ORCA_AGENT_HOOK_ROOT: 'R' }, + 'root' + ] + ])('runs the right script for %s and always exits 0', async (_case, env, expected) => { + const scripts = makeScripts() + const resolved: Record = Object.fromEntries( + Object.entries(env).map(([key, value]) => [key, value === 'R' ? scripts.root : value]) + ) + expect(await run({ ...resolved, HOME: scripts.home, MARKER: scripts.marker })).toEqual({ + code: 0, + ran: expected + }) + }) +}) diff --git a/src/main/codex/codex-hook-command-form.ts b/src/main/codex/codex-hook-command-form.ts new file mode 100644 index 000000000000..78d94294e2e9 --- /dev/null +++ b/src/main/codex/codex-hook-command-form.ts @@ -0,0 +1,94 @@ +/** + * The Codex hook command every Orca build writes, frozen per form. + * + * Why frozen: ~/.codex is shared by every Orca instance and build on this + * HOME. Bytes that depend on the build make two builds rewrite each other's + * entry, with a Codex trust session each time. A form changes only with a + * bump here, and a build never rewrites an entry of a higher form. + */ +export const CODEX_HOOK_COMMAND_FORM = 1 + +const FORM_MARKER = /orca-agent-hook-form=(\d+)/ + +// Why literals, not the shared hook constants: a change there must not move these bytes. +const POSIX_STDIN_DRAIN = '{ command -p cat 2>/dev/null || cat; } >/dev/null 2>&1 || :' + +// Why: a path of only these runs bare under every Windows host Codex uses +// (PowerShell 7 or 5.1 from the turn's shell, else %COMSPEC% /C); any other +// character takes the cmd spelling. Neither spelling can carry a marker, so +// later forms change the script, never these bytes. +const WINDOWS_BARE_PATH = /^[A-Za-z0-9_.:/~-]+$/ + +function buildPosixCommand(): string { + const rootScript = '"${ORCA_AGENT_HOOK_ROOT-}/agent-hooks/codex-hook.sh"' + const sharedScript = '"${HOME-}/.orca/agent-hooks/codex-hook.sh"' + // Why the root branch: dormant until Orca sets ORCA_AGENT_HOOK_ROOT, so the + // bytes need not change when it does. The shared branch needs a hook port, + // so a pane with hooks off and every shell outside Orca only drain stdin. + return [ + `: orca-agent-hook-form=${CODEX_HOOK_COMMAND_FORM};`, + `if [ -n "\${ORCA_PANE_KEY-}" ] && [ -n "\${ORCA_AGENT_HOOK_ROOT-}" ] && [ -f ${rootScript} ]; then /bin/sh ${rootScript} || :;`, + `elif [ -z "\${ORCA_AGENT_HOOK_ROOT-}" ] && [ -n "\${ORCA_PANE_KEY-}" ] && [ -n "\${ORCA_AGENT_HOOK_PORT-}" ] && [ -f ${sharedScript} ]; then /bin/sh ${sharedScript} || :;`, + `else ${POSIX_STDIN_DRAIN}; fi` + ].join(' ') +} + +// Why an absolute, unquoted, backslashed cmd.exe: PowerShell resolves a bare `cmd` +// through PATH (a cmd.bat earlier on PATH runs instead), reads a quoted first +// token as an expression, and cmd.exe parses a forward-slash argv0 as switches. +const WINDOWS_DIRECTORY = /^[A-Za-z]:(?:\/[A-Za-z0-9_.~-]+)+$/ +const COMSPEC_IN_SYSTEM32 = /^(.+)\/system32\/cmd\.exe$/i +// Why: still absolute when neither variable names a directory it can spell unquoted. +const DEFAULT_WINDOWS_DIRECTORY = 'C:/Windows' + +type WindowsDirectoryEnv = Readonly> + +function toForwardSlashDirectory(path: string | undefined): string { + return (path ?? '').replaceAll('\\', '/').replace(/\/+$/, '') +} + +function resolveWindowsDirectory(env: WindowsDirectoryEnv): string { + const systemRoot = toForwardSlashDirectory(env.SystemRoot) + if (WINDOWS_DIRECTORY.test(systemRoot)) { + return systemRoot + } + const comSpecRoot = COMSPEC_IN_SYSTEM32.exec(toForwardSlashDirectory(env.ComSpec))?.[1] ?? '' + return WINDOWS_DIRECTORY.test(comSpecRoot) ? comSpecRoot : DEFAULT_WINDOWS_DIRECTORY +} + +function buildWindowsCommand(scriptPath: string, env: WindowsDirectoryEnv): string { + const forwardSlashPath = scriptPath.replaceAll('\\', '/') + if (WINDOWS_BARE_PATH.test(forwardSlashPath)) { + return forwardSlashPath + } + // Why: `--%` passes the rest to cmd.exe verbatim under PowerShell, /v:off keeps + // a registry-enabled delayed expansion from expanding a `!NAME!` pair in the path, + // and the `@` stops cmd.exe stripping the quotes around a path holding & or ^. + const cmdExe = `${resolveWindowsDirectory(env)}/System32/cmd.exe`.replaceAll('/', '\\') + return `${cmdExe} --% /d /v:off /c @"${forwardSlashPath}"` +} + +/** + * `scriptPath` is the shared script at `~/.orca/agent-hooks`; only Windows forms + * embed it. On Windows the bytes depend only on it and on %SystemRoot% (else + * %ComSpec%), both fixed per machine. + */ +export function buildCodexHookCommand( + scriptPath: string, + platform: NodeJS.Platform = process.platform, + env: WindowsDirectoryEnv = process.env +): string { + return platform === 'win32' ? buildWindowsCommand(scriptPath, env) : buildPosixCommand() +} + +/** + * The form of an Orca-shaped Codex hook command. 0 is any form from before the + * frozen command, including every retired one. + */ +export function readCodexHookCommandForm(command: string, currentCommand: string): number { + if (command === currentCommand) { + return CODEX_HOOK_COMMAND_FORM + } + const marker = FORM_MARKER.exec(command) + return marker ? Number(marker[1]) : 0 +} diff --git a/src/main/codex/codex-hook-definition.ts b/src/main/codex/codex-hook-definition.ts index 641a5fc1b087..be42ead2c300 100644 --- a/src/main/codex/codex-hook-definition.ts +++ b/src/main/codex/codex-hook-definition.ts @@ -1,14 +1,12 @@ import { join } from 'node:path' import { getSharedManagedScriptPath, - buildWindowsHookPowerShellCommand, - wrapPosixHookCommand, - WINDOWS_CMD_SAFE_PATH, writeHooksJson, type HookDefinition } from '../agent-hooks/installer-utils' import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from '../agent-hooks/hook-stdin-contract' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' +import { buildCodexHookCommand } from './codex-hook-command-form' import { CODEX_HOOK_EVENT_LABEL, getCodexManagedScriptFileName } from './codex-hook-identity' import { getManagedScript } from './codex-hook-script' import type { CodexEventLabel } from './config-toml-trust' @@ -71,13 +69,7 @@ export function getManagedScriptPath(): string { } export function getManagedCommand(scriptPath: string): string { - if (process.platform !== 'win32') { - return wrapPosixHookCommand(scriptPath) - } - // Codex's default native Windows hook host is PowerShell; reuse it to avoid a second interpreter. - return WINDOWS_CMD_SAFE_PATH.test(scriptPath) - ? scriptPath - : buildWindowsHookPowerShellCommand(scriptPath) + return buildCodexHookCommand(scriptPath) } export type CodexManagedHookInstallMaterial = { diff --git a/src/main/codex/codex-hook-legacy-cleanup.ts b/src/main/codex/codex-hook-legacy-cleanup.ts index 415aabf61553..36bdff8df62e 100644 --- a/src/main/codex/codex-hook-legacy-cleanup.ts +++ b/src/main/codex/codex-hook-legacy-cleanup.ts @@ -1,18 +1,12 @@ -import { existsSync, readFileSync, statSync, unlinkSync } from 'node:fs' +import { existsSync, readFileSync, unlinkSync } from 'node:fs' import { join } from 'node:path' import { - createManagedCommandMatcher, hookDefinitionHasManagedCommand, readHooksJsonWithRaw, removeManagedCommands, writeHooksJson } from '../agent-hooks/installer-utils' import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path' -import { - isAgentStatusHooksEnabledForAgent, - type AgentStatusHooksSettings -} from '../../shared/agent-status-hooks-setting' -import { writeFileAtomically } from '../codex-accounts/fs-utils' import { findManagedTomlBlocks } from '../agent-hooks/managed-toml-ownership' import { writeConfigAtomically, type CodexTrustEntry } from './config-toml-trust' import { @@ -20,87 +14,53 @@ import { getSystemCodexConfigTomlPath, getSystemConfigPath } from './codex-hook-definition' -import { getCodexManagedScriptFileName } from './codex-hook-identity' +import { isRetiredCodexHookCommand } from './codex-hook-retired-commands' import { getSystemCodexHomePath } from './codex-home-paths' import { collectManagedTrustEntries, - removeSelfComputedMatchingTrustEntries, - removeSystemManagedHookTrustEntries + removeSelfComputedMatchingTrustEntries } from './codex-hook-trust-cleanup' -import { readCodexTrustGrantLedgerHomeForReconciliation } from './codex-managed-trust-reconciliation' import { runExclusivelyForCodexTrustConfig } from './codex-trust-config-mutation-queue' -import { mutateRealHomeHooksPreservingUserTrust } from './codex-user-hook-trust-rebase' +import { mutateRealHomeHooksPreservingUserTrust } from './codex-user-hook-trust-moves' const LEGACY_ORCA_PROFILE_NAME = 'orca-agent-status' const LEGACY_ORCA_PROFILE_BLOCK_START = '# BEGIN ORCA AGENT STATUS HOOKS' const LEGACY_ORCA_PROFILE_BLOCK_END = '# END ORCA AGENT STATUS HOOKS' -// Why: when the real-home lane owns ~/.codex/hooks.json (system-default flag ON -// with hooks enabled), the legacy system-home sweep must stand down or every -// managed install would delete the entry the real-home installer just wrote. -// Injected as a gate because this module is bundled into plain-node CLI entries -// that have no settings store; the CLI default keeps the sweep active. -let systemCodexHomeHookSweepSuppressed: () => boolean = () => false - -export function setSystemCodexHomeHookSweepSuppressed(gate: () => boolean): void { - systemCodexHomeHookSweepSuppressed = gate -} - -// Why per agent: Codex turned off must re-arm the sweep exactly like the global switch off, -// or its remove() leaves Orca's entry in the real ~/.codex. -export function shouldSuppressSystemCodexHomeHookSweep(args: { - isHostSystemDefaultRealHome: boolean - settings: AgentStatusHooksSettings -}): boolean { - return ( - args.isHostSystemDefaultRealHome && isAgentStatusHooksEnabledForAgent(args.settings, 'codex') - ) -} - function getLegacyCodexProfileTomlPath(): string { return join(getSystemCodexHomePath(), `${LEGACY_ORCA_PROFILE_NAME}.config.toml`) } export function cleanupLegacySystemManagedHooks(): Promise { - // Why: shares the real-home lane with ensureRealHomeCodexHookState — both - // capture, mutate and roll back the user's ~/.codex/config.toml. + // Why: shares the real-home lane with ensureRealHomeCodexHookState; both + // write the user's ~/.codex/hooks.json and its trust in config.toml. return runExclusivelyForCodexTrustConfig( getSystemCodexConfigTomlPath(), sweepLegacySystemManagedHooks ) } +/** + * Removes only retired Orca command forms from the user's ~/.codex/hooks.json. + * + * Why never the current entry or its trust: every Orca instance on this HOME + * shares that entry, and this runs on every install, including launch prep for + * any pane. Removing it is reserved for an explicit opt-out. + */ async function sweepLegacySystemManagedHooks(): Promise { - if (systemCodexHomeHookSweepSuppressed()) { - return - } const legacyConfigPath = getSystemConfigPath() const runtimeConfigPath = getConfigPath() if (legacyConfigPath === runtimeConfigPath) { return } - const systemHomePath = getSystemCodexHomePath() - const hasRecordedRealHomeGrant = - readCodexTrustGrantLedgerHomeForReconciliation(systemHomePath) !== null // Why: the pre-write guard below compares against these bytes; a separate // later read would let a concurrent save land between parse and snapshot. const { raw: previousRaw, config } = readHooksJsonWithRaw(legacyConfigPath) - // Why: `config === null` with no raw is the "could not read" answer, not the - // "no hooks here" one — the branch below removes managed trust entries AND - // their grant-ledger record, so acting on it would discard approvals over a - // read that merely failed. A genuine absence still returns `config: {}`. - if (config === null && previousRaw === null) { - return - } if (!config?.hooks || previousRaw === null) { - if (hasRecordedRealHomeGrant) { - removeSystemManagedHookTrustEntries(systemHomePath, legacyConfigPath) - } return } - const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName()) const nextHooks = { ...config.hooks } const trustEntries: CodexTrustEntry[] = [] let removedManagedHook = false @@ -112,15 +72,15 @@ async function sweepLegacySystemManagedHooks(): Promise { legacyConfigPath, eventName, definitions, - isManagedCommand + isRetiredCodexHookCommand ) // Why: user hook configs can be large; avoid the argument limit from push(...entries). for (const entry of eventTrustEntries) { trustEntries.push(entry) } - const cleaned = removeManagedCommands(definitions, isManagedCommand) + const cleaned = removeManagedCommands(definitions, isRetiredCodexHookCommand) removedManagedHook ||= definitions.some((definition) => - hookDefinitionHasManagedCommand(definition, isManagedCommand) + hookDefinitionHasManagedCommand(definition, isRetiredCodexHookCommand) ) if (cleaned.length === 0) { delete nextHooks[eventName] @@ -129,15 +89,13 @@ async function sweepLegacySystemManagedHooks(): Promise { } } - // Why: Codex hooks moved to Orca's managed CODEX_HOME; stale ~/.codex entries would keep external Codex sessions reporting into Orca. + // Why: Codex hooks moved to Orca's managed CODEX_HOME in #2350; hooks from before then would keep external Codex sessions reporting into Orca. if (removedManagedHook) { // Why: this is the user's system hooks file, not Orca's runtime copy. - // Remove only stale Orca hook entries and preserve other managers' metadata. + // Remove only retired Orca hook entries and preserve other managers' metadata. const hooksWritePath = resolveHooksJsonWritePath(legacyConfigPath) - const previousMode = statSync(hooksWritePath).mode - await mutateRealHomeHooksPreservingUserTrust({ + mutateRealHomeHooksPreservingUserTrust({ sourcePath: legacyConfigPath, - runtimeHomePath: systemHomePath, tomlPath: getSystemCodexConfigTomlPath(), beforeHooks: config.hooks, afterHooks: nextHooks, @@ -146,23 +104,15 @@ async function sweepLegacySystemManagedHooks(): Promise { readFileSync(legacyConfigPath, 'utf-8') !== previousRaw || resolveHooksJsonWritePath(legacyConfigPath) !== hooksWritePath ) { - // Why: the pre-mutation RPC may overlap a user save; downgrade must - // never replace that newer dotfiles generation with our stale parse. - throw new Error('System Codex hooks changed during trust repair') + // Why: another process may have saved since the read; never replace + // that newer dotfiles generation with this stale parse. + throw new Error('System Codex hooks changed since Orca read them') } writeHooksJson(hooksWritePath, { ...config, hooks: nextHooks }, { preserveMode: true }) - }, - restoreHooks: () => writeFileAtomically(hooksWritePath, previousRaw, { mode: previousMode }) + } }) - // Why: stale dev/version entries can reference an older managed script - // path that is not represented by the current grant ledger. removeSelfComputedMatchingTrustEntries(getSystemCodexConfigTomlPath(), trustEntries) } - if (removedManagedHook || hasRecordedRealHomeGrant) { - // Why: the ledger recognizes Codex-computed hashes and remains a retry - // marker if a prior cleanup removed hooks.json but could not update TOML. - removeSystemManagedHookTrustEntries(systemHomePath, legacyConfigPath) - } } export function stripLegacyManagedProfileBlock(content: string): string { diff --git a/src/main/codex/codex-hook-local-install.ts b/src/main/codex/codex-hook-local-install.ts index be3582d3879e..0c76835e4153 100644 --- a/src/main/codex/codex-hook-local-install.ts +++ b/src/main/codex/codex-hook-local-install.ts @@ -26,7 +26,11 @@ import { import { getCodexManagedScriptFileName } from './codex-hook-identity' import { cleanupLegacyManagedHookRepresentations } from './codex-hook-legacy-cleanup' import { getManagedScript } from './codex-hook-script' -import { grantManagedCodexHookTrust } from './codex-hook-trust-grant' +import { + grantManagedCodexHookTrust, + type CodexManagedTrustGrantPlan +} from './codex-hook-trust-grant' +import { removeSelfComputedTrustBeforeGrant } from './codex-managed-trust-grant-plan' import { removeStaleRuntimeHookTrustEntries } from './codex-hook-trust-cleanup' import { promoteCodexRuntimeHookApprovalsToSystem, @@ -145,14 +149,18 @@ export async function installCodexHooksExclusively( // then carry Codex's verbatim hashes into stale cleanup so it cannot // delete what Codex just wrote. Mirrored user trust keeps its existing // verbatim-carry lane either way. - const grant = await grantManagedCodexHookTrust({ + const grantPlan: CodexManagedTrustGrantPlan = { runtimeHomePath, tomlPath, managedCommand: command, managedEntries: managedTrustEntries, host: { kind: 'native' }, telemetryLane: 'managed' - }) + } + // Why: the fallback below writes this trust back if the session fails. + const grant = await grantManagedCodexHookTrust(grantPlan, () => + removeSelfComputedTrustBeforeGrant(grantPlan) + ) if (grant.lane === 'rpc') { recentGrantEntries = grant.entries upsertHookTrustEntries(tomlPath, mirroredTrustEntries) diff --git a/src/main/codex/codex-hook-local-maintenance.ts b/src/main/codex/codex-hook-local-maintenance.ts index 8b45049e5160..bb89b4e6dc84 100644 --- a/src/main/codex/codex-hook-local-maintenance.ts +++ b/src/main/codex/codex-hook-local-maintenance.ts @@ -10,6 +10,7 @@ import { upsertHookTrustEntries } from './config-toml-trust' import { getCodexConfigTomlPath, getConfigPath, writeCodexHooksJson } from './codex-hook-definition' import { getCodexManagedScriptFileName } from './codex-hook-identity' import { cleanupLegacyManagedHookRepresentations } from './codex-hook-legacy-cleanup' +import { removeRealHomeCodexHookForOptOut } from './codex-real-home-hook-install' import { removeRuntimeManagedHookTrustEntries, removeStaleRuntimeHookTrustEntries @@ -96,6 +97,7 @@ export async function removeCodexHooksExclusively( const config = readHooksJson(configPath) if (!config) { // Why: a malformed hooks.json shouldn't strand old hooks in ~/.codex or the legacy profile after disabling. + await removeRealHomeCodexHookForOptOut() await cleanupLegacyManagedHookRepresentations() return { agent: 'codex', @@ -129,6 +131,9 @@ export async function removeCodexHooksExclusively( // Why: drop trust entries so config.toml doesn't accumulate dead [hooks.state] blocks across install/remove cycles. removeRuntimeManagedHookTrustEntries(configPath) + // Why here and nowhere automatic: the real-home entry is shared by every Orca + // on this HOME, so only the user's explicit opt-out may strip it. + await removeRealHomeCodexHookForOptOut() await cleanupLegacyManagedHookRepresentations() return getStatus() diff --git a/src/main/codex/codex-hook-retired-commands.test.ts b/src/main/codex/codex-hook-retired-commands.test.ts new file mode 100644 index 000000000000..f797b70dabce --- /dev/null +++ b/src/main/codex/codex-hook-retired-commands.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { + buildWindowsHookPowerShellCommand, + wrapPosixHookCommand, + wrapWindowsHookCommand +} from '../agent-hooks/installer-utils' +import { buildCodexHookCommand } from './codex-hook-command-form' +import { getManagedCommand, getManagedScriptPath } from './codex-hook-definition' +import { isRetiredCodexHookCommand } from './codex-hook-retired-commands' + +// Frozen from the real-home lane's Windows launcher before Windows left that lane. +function encodedLauncher(scriptPath: string): string { + const quoted = `'${scriptPath.replaceAll("'", "''")}'` + const script = `if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0` + return `C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${Buffer.from(script, 'utf16le').toString('base64')}` +} + +describe('isRetiredCodexHookCommand', () => { + it.each([ + ['the #1019 double-quoted form', '/bin/sh "/u/Library/orca/agent-hooks/codex-hook.sh"'], + [ + 'the #1536 exec-guarded form', + "if [ -x '/u/.orca/agent-hooks/codex-hook.sh' ]; then /bin/sh '/u/.orca/agent-hooks/codex-hook.sh'; fi" + ], + [ + 'an exec-guarded form with a quoted apostrophe', + "if [ -x '/u/o'\\''k/agent-hooks/codex-hook.sh' ]; then /bin/sh '/u/o'\\''k/agent-hooks/codex-hook.sh'; fi" + ], + [ + 'a per-userData Windows path', + 'C:\\Users\\u\\AppData\\Roaming\\orca\\agent-hooks\\codex-hook.cmd' + ], + [ + "the real-home lane's first file-guarded form", + "if [ -f '/u/.orca/agent-hooks/codex-hook.sh' ] && [ -r '/u/.orca/agent-hooks/codex-hook.sh' ] && [ -x '/u/.orca/agent-hooks/codex-hook.sh' ]; then /bin/sh '/u/.orca/agent-hooks/codex-hook.sh'; else cat >/dev/null 2>&1 || :; fi" + ], + [ + "the real-home lane's encoded Windows launcher", + encodedLauncher("C:\\Users\\Jo O'Neil\\.orca\\agent-hooks\\codex-hook.cmd") + ] + ])('matches %s', (_case, command) => { + expect(isRetiredCodexHookCommand(command)).toBe(true) + }) + + // Why: every build writes the frozen form and older builds still write theirs; + // sweeping either would strip a live entry another Orca relies on. + it.each([ + ["this build's command", getManagedCommand(getManagedScriptPath())], + [ + "this build's cmd.exe spelling for a spaced profile", + buildCodexHookCommand('C:\\Users\\Jo Smith\\.orca\\agent-hooks\\codex-hook.cmd', 'win32', { + SystemRoot: 'C:\\Windows' + }) + ], + [ + "this build's cmd.exe spelling with Windows on another drive", + buildCodexHookCommand('C:\\Users\\Jo Smith\\.orca\\agent-hooks\\codex-hook.cmd', 'win32', { + SystemRoot: 'D:\\Windows' + }) + ], + [ + 'the bare-cmd spelling that the managed installer and app start convert', + 'cmd --% /d /c @"C:/Users/Jo Smith/.orca/agent-hooks/codex-hook.cmd"' + ], + [ + 'the PowerShell-text form that app start converts', + "<# orca-agent-hook-form=1 #> if ($env:ORCA_PANE_KEY) { & 'C:/Users/Jo Smith/.orca/agent-hooks/codex-hook.cmd' }; exit 0" + ], + ["an older build's command", wrapPosixHookCommand('/other/.orca/agent-hooks/codex-hook.sh')], + [ + "an older build's Windows launcher", + buildWindowsHookPowerShellCommand('C:\\Users\\Jo Smith\\.orca\\agent-hooks\\codex-hook.cmd') + ], + [ + 'the shared encoded launcher', + wrapWindowsHookCommand('C:\\Users\\Jo Smith\\.orca\\agent-hooks\\codex-hook.cmd') + ], + ['a user script with the same name', '/bin/sh "/u/bin/codex-hook.sh"'], + [ + "another agent's encoded launcher", + encodedLauncher('C:\\Users\\Jo Smith\\.orca\\agent-hooks\\claude-hook.cmd') + ], + ['a user hook', 'my-stop-hook.sh'], + ['no command', undefined] + ])('leaves %s alone', (_case, command) => { + expect(isRetiredCodexHookCommand(command)).toBe(false) + }) +}) diff --git a/src/main/codex/codex-hook-retired-commands.ts b/src/main/codex/codex-hook-retired-commands.ts new file mode 100644 index 000000000000..7eaf969d5a67 --- /dev/null +++ b/src/main/codex/codex-hook-retired-commands.ts @@ -0,0 +1,72 @@ +import { posix, win32 } from 'node:path' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { getManagedScriptPath } from './codex-hook-definition' + +// Why enumerated: every Orca build and instance writes the same current command, +// so "any Orca-looking entry that is not mine" would strip a newer or older +// build's live entry. Only forms no build writes any more may be swept. +// #1019: `/bin/sh "/agent-hooks/codex-hook.sh"`. +const DOUBLE_QUOTED_SH = /^\/bin\/sh "([^"]+)"$/ +// #1536 until hooks left ~/.codex (#2350): `if [ -x '

' ]; then /bin/sh '

'; fi`. +const EXEC_GUARDED_SH = /^if \[ -x ('(?:[^']|'\\'')*') \]; then \/bin\/sh \1; fi$/ +// Real-home lane (#9501 until #10885): the file-guarded form draining with a +// bare `cat`. Never the `command -p cat` drain, which builds from before the +// frozen command still write and so must never be swept. +const BARE_CAT_FILE_GUARDED_SH = + /^if \[ -f ('(?:[^']|'\\'')*') \] && \[ -r \1 \] && \[ -x \1 \]; then \/bin\/sh \1; else cat >\/dev\/null 2>&1 \|\| :; fi$/ +// Windows' real-home lane (#9501 until #10221 took Windows off it) wrapped the +// launcher in an encoded command for a non-cmd-safe script path. Codex's current +// launcher is unencoded, and the shared encoded launcher since #14825 prefixes +// its payload, so the exact payload check below matches only the retired form. +const ENCODED_POWERSHELL = + /^\S+\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand ([A-Za-z0-9+/]+={0,2})$/ +const DECODED_LAUNCHER_PATH = /^if \(Test-Path -LiteralPath ('(?:[^']|'')*') -PathType Leaf\) / + +function isAgentHooksScript(scriptPath: string, fileName: string): boolean { + const pathApi = scriptPath.includes('\\') ? win32 : posix + return ( + pathApi.basename(scriptPath).toLowerCase() === fileName && + pathApi.basename(pathApi.dirname(scriptPath)) === 'agent-hooks' + ) +} + +function unquotePosix(quoted: string): string { + return quoted.slice(1, -1).replaceAll("'\\''", "'") +} + +/** True only for a Codex hook command a retired Orca build wrote into ~/.codex. */ +export function isRetiredCodexHookCommand(command: string | undefined): boolean { + if (!command) { + return false + } + const doubleQuoted = DOUBLE_QUOTED_SH.exec(command) + if (doubleQuoted) { + return isAgentHooksScript(doubleQuoted[1]!, 'codex-hook.sh') + } + const shGuarded = EXEC_GUARDED_SH.exec(command) ?? BARE_CAT_FILE_GUARDED_SH.exec(command) + if (shGuarded) { + return isAgentHooksScript(unquotePosix(shGuarded[1]!), 'codex-hook.sh') + } + const encoded = ENCODED_POWERSHELL.exec(command) + if (encoded) { + const decoded = Buffer.from(encoded[1]!, 'base64').toString('utf16le') + const decodedLauncher = DECODED_LAUNCHER_PATH.exec(decoded) + if (!decodedLauncher) { + return false + } + const quoted = decodedLauncher[1]! + return ( + decoded === + `if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0` && + isAgentHooksScript(quoted.slice(1, -1).replaceAll("''", "'"), 'codex-hook.cmd') + ) + } + // Why: before #1546 Windows wrote a bare per-userData script path; the bare + // shared path, in either slash direction, is still written, so it never matches. + return ( + win32.isAbsolute(command) && + isAgentHooksScript(command, 'codex-hook.cmd') && + normalizeRuntimePathForComparison(command) !== + normalizeRuntimePathForComparison(getManagedScriptPath()) + ) +} diff --git a/src/main/codex/codex-hook-sweep-per-agent-opt-out.test.ts b/src/main/codex/codex-hook-sweep-per-agent-opt-out.test.ts index 780f0dbbf80e..25f839f5266f 100644 --- a/src/main/codex/codex-hook-sweep-per-agent-opt-out.test.ts +++ b/src/main/codex/codex-hook-sweep-per-agent-opt-out.test.ts @@ -1,9 +1,8 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' import type * as Os from 'node:os' import { join } from 'node:path' import { wrapPosixHookCommand } from '../agent-hooks/installer-utils' -import type { GlobalSettings } from '../../shared/global-settings-types' import { setupCodexHookHomes } from './hook-service-test-harness' const { getPathMock, homedirMock } = vi.hoisted(() => ({ @@ -26,20 +25,9 @@ vi.mock('os', async (importOriginal) => { }) import { CodexHookService } from './hook-service' -import { - setSystemCodexHomeHookSweepSuppressed, - shouldSuppressSystemCodexHomeHookSweep -} from './codex-hook-legacy-cleanup' const homes = setupCodexHookHomes(homedirMock, getPathMock) -type HookSettings = Pick - -const CODEX_OFF: HookSettings = { agentStatusHooksEnabled: true, disabledTuiAgents: ['codex'] } -const ALL_ON: HookSettings = { agentStatusHooksEnabled: true, disabledTuiAgents: [] } -const OTHER_OFF: HookSettings = { agentStatusHooksEnabled: true, disabledTuiAgents: ['claude'] } -const GLOBAL_OFF: HookSettings = { agentStatusHooksEnabled: false, disabledTuiAgents: [] } - function orcaEntryCommand(): string { const scriptPath = join( homes.userDataDir, @@ -72,39 +60,11 @@ function realHomeStopHooks(systemHooksPath: string): unknown { return JSON.parse(readFileSync(systemHooksPath, 'utf-8')).hooks.Stop } -describe('system ~/.codex sweep gate', () => { - afterEach(() => { - setSystemCodexHomeHookSweepSuppressed(() => false) - }) - - it.each([ - { label: 'Codex turned off', settings: CODEX_OFF, suppressed: false }, - { label: 'the global switch off', settings: GLOBAL_OFF, suppressed: false }, - { label: 'every agent on', settings: ALL_ON, suppressed: true }, - { label: 'another agent turned off', settings: OTHER_OFF, suppressed: true } - ])('on the real-home lane with $label, suppressed=$suppressed', ({ settings, suppressed }) => { - expect( - shouldSuppressSystemCodexHomeHookSweep({ isHostSystemDefaultRealHome: true, settings }) - ).toBe(suppressed) - }) - - it('never suppresses off the real-home lane', () => { - expect( - shouldSuppressSystemCodexHomeHookSweep({ - isHostSystemDefaultRealHome: false, - settings: ALL_ON - }) - ).toBe(false) - }) - - it('turning Codex off removes the Orca entry from the real ~/.codex and keeps user hooks', async () => { +// Why: Codex turned off per agent reaches remove(), the explicit opt-out that alone +// strips the shared real-home entry. +describe('Codex turned off per agent', () => { + it('removes the Orca entry from the real ~/.codex and keeps user hooks', async () => { const systemHooksPath = seedRealHomeHooks() - setSystemCodexHomeHookSweepSuppressed(() => - shouldSuppressSystemCodexHomeHookSweep({ - isHostSystemDefaultRealHome: true, - settings: CODEX_OFF - }) - ) await new CodexHookService().remove() @@ -112,21 +72,4 @@ describe('system ~/.codex sweep gate', () => { { hooks: [{ type: 'command', command: 'user-hook' }] } ]) }) - - it('leaves the real-home entry in place while Codex hooks are on', async () => { - const systemHooksPath = seedRealHomeHooks() - setSystemCodexHomeHookSweepSuppressed(() => - shouldSuppressSystemCodexHomeHookSweep({ - isHostSystemDefaultRealHome: true, - settings: ALL_ON - }) - ) - - await new CodexHookService().remove() - - expect(realHomeStopHooks(systemHooksPath)).toEqual([ - { hooks: [{ type: 'command', command: 'user-hook' }] }, - { hooks: [{ type: 'command', command: orcaEntryCommand() }] } - ]) - }) }) diff --git a/src/main/codex/codex-hook-trust-grant.test.ts b/src/main/codex/codex-hook-trust-grant.test.ts index 44ee3f0f7ccc..bfaf8889174d 100644 --- a/src/main/codex/codex-hook-trust-grant.test.ts +++ b/src/main/codex/codex-hook-trust-grant.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { @@ -15,6 +15,7 @@ import { grantManagedCodexHookTrust, type CodexManagedTrustGrantPlan } from './codex-hook-trust-grant' +import { removeSelfComputedTrustBeforeGrant } from './codex-managed-trust-grant-plan' import { setCodexTrustGrantTelemetry } from './codex-trust-grant-telemetry' import { readCodexTrustGrantLedgerHome } from './codex-trust-grant-ledger' import { @@ -164,7 +165,8 @@ describe('grantManagedCodexHookTrust', () => { ...managedEntry('stop'), sourcePath: String.raw`C:\Users\Alice\.codex\hooks.json` } - const plan = buildPlan([entry]) + const plan: CodexManagedTrustGrantPlan = { ...buildPlan([entry]), telemetryLane: 'managed' } + // Why: the managed fallback lane's write, both separator variants. upsertHookTrustEntries(plan.tomlPath, [entry]) expect(readHookTrustEntries(plan.tomlPath).get(computeTrustKey(entry))?.trustedHash).toBe( computeTrustedHash(entry) @@ -175,10 +177,28 @@ describe('grantManagedCodexHookTrust', () => { }) _internals.setGrantSessionRunner(runner) - expect(await grantManagedCodexHookTrust(plan)).toMatchObject({ lane: 'rpc' }) + expect( + await grantManagedCodexHookTrust(plan, () => removeSelfComputedTrustBeforeGrant(plan)) + ).toMatchObject({ lane: 'rpc' }) expect(runner).toHaveBeenCalledTimes(1) }) + it("runs the caller's pre-session step only when a session runs", async () => { + const entries = [managedEntry('session_start')] + _internals.setGrantSessionRunner(async () => grantedSessionResult(entries)) + const plan = buildPlan(entries) + const beforeSession = vi.fn() + + expect(await grantManagedCodexHookTrust(plan, beforeSession)).toMatchObject({ lane: 'rpc' }) + expect(beforeSession).toHaveBeenCalledTimes(1) + upsertHookTrustEntries(plan.tomlPath, [ + { ...entries[0], trustedHash: 'sha256:codex-session_start' } + ]) + // Why: a clear before the ledger check would delete the record the ledger proves, forcing a session per launch. + expect(await grantManagedCodexHookTrust(plan, beforeSession)).toMatchObject({ lane: 'rpc' }) + expect(beforeSession).toHaveBeenCalledTimes(1) + }) + it('skips the RPC session while the ledger grant still holds, and re-grants on config drift', async () => { const entries = [managedEntry('session_start')] const runner = vi.fn(async () => grantedSessionResult(entries)) @@ -266,6 +286,10 @@ describe('grantManagedCodexHookTrust', () => { }) expect(runner).toHaveBeenCalledTimes(1) expect(codexAppServerCapabilityCache.shouldTry('native')).toBe(true) + // Why: an inline grant costs its launch the full timeout, so it keeps the long cooldown. + vi.setSystemTime(1_000 + 10_001) + expect(await grantManagedCodexHookTrust(plan)).toMatchObject({ reason: 'retry-cached' }) + expect(runner).toHaveBeenCalledTimes(1) runner.mockImplementation(async () => grantedSessionResult(entries)) vi.setSystemTime(1_000 + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS) @@ -273,6 +297,28 @@ describe('grantManagedCodexHookTrust', () => { expect(runner).toHaveBeenCalledTimes(2) }) + it('neither starts nor waits on a cooldown for a background grant', async () => { + const entries = [managedEntry('session_start')] + const runner = vi.fn((): Promise => { + throw new Error('codex app-server exited before completing the session') + }) + _internals.setGrantSessionRunner(runner) + const background: CodexManagedTrustGrantPlan = { ...buildPlan(entries), background: true } + const inline = buildPlan(entries) + + // Why: the real-home lane schedules a background retry; a second schedule here could only disagree. + expect(await grantManagedCodexHookTrust(background)).toMatchObject({ reason: 'error' }) + expect(_internals.transientCooldownCountForTests()).toBe(0) + expect(await grantManagedCodexHookTrust(background)).toMatchObject({ reason: 'error' }) + expect(runner).toHaveBeenCalledTimes(2) + + // Why: the launch-path cooldown stays on its own lane. + expect(await grantManagedCodexHookTrust(inline)).toMatchObject({ reason: 'error' }) + expect(await grantManagedCodexHookTrust(inline)).toMatchObject({ reason: 'retry-cached' }) + expect(await grantManagedCodexHookTrust(background)).toMatchObject({ reason: 'error' }) + expect(runner).toHaveBeenCalledTimes(4) + }) + it('bounds transient cooldowns when host identities churn', async () => { _internals.setGrantSessionRunner(() => { throw new Error('spawn ETIMEDOUT') @@ -330,14 +376,16 @@ describe('grantManagedCodexHookTrust', () => { }) }) - it('restores exact config bytes before fallback after a mutating RPC error', async () => { + // Why no restore: the session writes trust only at Orca's own keys, and a + // snapshot restore would also undo a save that landed during it. + it('keeps a config.toml save made during a failed session', async () => { const entries = [managedEntry('session_start')] const plan = buildPlan(entries) - const original = '# user formatting\r\n[hooks]\r\n' mkdirSync(runtimeHomeDir, { recursive: true }) - writeFileSync(plan.tomlPath, original) + writeFileSync(plan.tomlPath, '# user formatting\r\n[hooks]\r\n') + const savedDuringSession = '# user formatting\r\n[hooks]\r\nmodel = "saved-during-session"\r\n' _internals.setGrantSessionRunner(async () => { - writeFileSync(plan.tomlPath, '[hooks.state."rpc-partial"]\ntrusted_hash = "changed"\n') + writeFileSync(plan.tomlPath, savedDuringSession) throw new Error('post-write transport failure') }) @@ -345,15 +393,16 @@ describe('grantManagedCodexHookTrust', () => { lane: 'fallback', reason: 'error' }) - expect(readFileSync(plan.tomlPath, 'utf8')).toBe(original) + expect(readFileSync(plan.tomlPath, 'utf8')).toBe(savedDuringSession) }) - it('removes an RPC-created config before fallback when none existed', async () => { + it('keeps a config.toml created during a failed session', async () => { const entries = [managedEntry('session_start')] const plan = buildPlan(entries) mkdirSync(runtimeHomeDir, { recursive: true }) + const createdDuringSession = 'model = "created-during-session"\n' _internals.setGrantSessionRunner(async () => { - writeFileSync(plan.tomlPath, '[hooks.state."rpc-partial"]\ntrusted_hash = "changed"\n') + writeFileSync(plan.tomlPath, createdDuringSession) return { outcome: 'verify-failed', reason: 'post-write listing failed', @@ -365,7 +414,7 @@ describe('grantManagedCodexHookTrust', () => { lane: 'fallback', reason: 'verify-failed' }) - expect(existsSync(plan.tomlPath)).toBe(false) + expect(readFileSync(plan.tomlPath, 'utf8')).toBe(createdDuringSession) }) it('honors the ops kill switch env flag', async () => { @@ -381,35 +430,6 @@ describe('grantManagedCodexHookTrust', () => { expect(runner).not.toHaveBeenCalled() }) - // Why (#16441): the grant used to run through spawnSync, so two grants on one - // config.toml were impossible by construction. Now they must queue — an - // interleaved capture/restore pair resurrects trust the other run removed. - it('serializes concurrent grants that share one config.toml', async () => { - const entries = [managedEntry('session_start')] - const plan = buildPlan(entries) - let inFlight = 0 - let maxInFlight = 0 - const releases: (() => void)[] = [] - _internals.setGrantSessionRunner(async () => { - inFlight += 1 - maxInFlight = Math.max(maxInFlight, inFlight) - await new Promise((resolve) => releases.push(resolve)) - inFlight -= 1 - return grantedSessionResult(entries) - }) - - const first = grantManagedCodexHookTrust(plan) - const second = grantManagedCodexHookTrust(plan) - await vi.waitFor(() => expect(releases).toHaveLength(1)) - releases[0]!() - await first - await vi.waitFor(() => expect(releases).toHaveLength(2)) - releases[1]!() - await second - - expect(maxInFlight).toBe(1) - }) - it('lets grants on different config.toml paths overlap', async () => { const entries = [managedEntry('session_start')] const otherHome = join(userDataDir, 'codex-accounts', 'other', 'home') diff --git a/src/main/codex/codex-hook-trust-grant.ts b/src/main/codex/codex-hook-trust-grant.ts index d4b2f0fa3591..e413e2e497b5 100644 --- a/src/main/codex/codex-hook-trust-grant.ts +++ b/src/main/codex/codex-hook-trust-grant.ts @@ -7,6 +7,7 @@ import { import { classifyCodexTrustGrantError, emitCodexTrustGrantTelemetry, + type CodexTrustGrantErrorClass, type CodexTrustGrantFallbackReason, type CodexTrustGrantTelemetryLane, type CodexTrustGrantVerifyClass @@ -22,8 +23,6 @@ import { type CodexTrustGrantLedgerEntry } from './codex-trust-grant-ledger' import type { CodexTrustEntry } from './config-toml-trust' -import { captureCodexTrustConfig, restoreCodexTrustConfig } from './codex-trust-config-rollback' -import { runExclusivelyForCodexTrustConfig } from './codex-trust-config-mutation-queue' import { resolveCodexTrustGrantHost, type ResolvedCodexTrustGrantHost @@ -31,28 +30,26 @@ import { import { buildExpectedEntries, findLedgerGrant, - removeSelfComputedTrustBeforeGrant, type CodexManagedTrustGrantPlan, type ExpectedManagedEntry } from './codex-managed-trust-grant-plan' import { isCodexStateDbBackfillPending } from './codex-state-db' +import { + clearCodexTrustGrantCooldown, + countCodexTrustGrantCooldowns, + isCodexTrustGrantCoolingDown, + resetCodexTrustGrantCooldowns, + startCodexTrustGrantCooldown +} from './codex-trust-grant-cooldown' -// Why: a transiently hung app-server must not block launch prep on every pane. -// The legacy lane remains available while a short, host-scoped cooldown runs. -export const CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS = 5 * 60_000 -const MAX_TRANSIENT_TRUST_COOLDOWNS = 256 +export { CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS } from './codex-trust-grant-cooldown' -/** - * Ops escape hatch (not a setting): forces the unchanged fallback lane for the - * *managed* grant only. - * - * Scope, because the name reads broader than it is: the real-home rebase - * (`mutateRealHomeHooksPreservingUserTrust`) still runs its own inspect/repair - * app-server sessions when Orca's insertion shifts a user's hook positions, and - * does not read this flag. That is unchanged from before the grant went async — - * those sessions simply used to block the main thread instead. Widening the flag - * to cover the rebase is a follow-up, not something this constant already does. - */ +// Why: a cold `codex app-server` on a loaded Mac took over 10 s; a background +// grant blocks no launch, so it can wait for one. The session's own kill timer +// bounds the whole grant: everything before it is synchronous on native. +export const CODEX_BACKGROUND_TRUST_GRANT_TIMEOUT_MS = 30_000 + +/** Ops escape hatch (not a setting): forces the fallback lane for every trust grant. */ const DISABLE_ENV_FLAG = 'ORCA_DISABLE_CODEX_TRUST_RPC' export type { CodexManagedTrustGrantPlan } @@ -60,7 +57,11 @@ export type { CodexTrustGrantFallbackReason, CodexTrustGrantTelemetryLane } export type CodexManagedTrustGrantOutcome = | { lane: 'rpc'; entries: CodexTrustEntry[] } - | { lane: 'fallback'; reason: CodexTrustGrantFallbackReason } + | { + lane: 'fallback' + reason: CodexTrustGrantFallbackReason + errorClass?: CodexTrustGrantErrorClass + } const diagnostics = { granted: 0, @@ -70,7 +71,6 @@ const diagnostics = { lastFallbackReason: null as CodexTrustGrantFallbackReason | null } export type CodexTrustGrantDiagnostics = typeof diagnostics -const transientRetryAfterByHost = new Map() export const getCodexTrustGrantDiagnostics = (): CodexTrustGrantDiagnostics => ({ ...diagnostics }) @@ -94,6 +94,7 @@ function fallback( if (reason === 'verify-failed') { diagnostics.verifyFailed += 1 } + const errorClass = reason === 'error' ? classifyCodexTrustGrantError(detail) : undefined console.warn( `[codex-trust-grant] falling back to self-computed trust (reason=${reason}, host=${plan.host.kind})`, detail ?? '' @@ -103,22 +104,10 @@ function fallback( hostKind: plan.host.kind, lane: plan.telemetryLane, reason, - ...(reason === 'error' ? { errorClass: classifyCodexTrustGrantError(detail) } : {}), + ...(errorClass !== undefined ? { errorClass } : {}), ...(verifyClass !== undefined ? { verifyClass } : {}) }) - return { lane: 'fallback', reason } -} - -function startTransientCooldown(hostKey: CodexAppServerHostKey): void { - transientRetryAfterByHost.delete(hostKey) - transientRetryAfterByHost.set(hostKey, Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS) - while (transientRetryAfterByHost.size > MAX_TRANSIENT_TRUST_COOLDOWNS) { - const oldest = transientRetryAfterByHost.keys().next().value - if (oldest === undefined) { - break - } - transientRetryAfterByHost.delete(oldest) - } + return { lane: 'fallback', reason, ...(errorClass !== undefined ? { errorClass } : {}) } } type GrantAttempt = { @@ -126,23 +115,23 @@ type GrantAttempt = { expected: ExpectedManagedEntry[] hostKey: CodexAppServerHostKey currentStamp: CodexTrustGrantBinaryStamp | null - configSnapshot: ReturnType startedAtMs: number } /** Post-session verification, ledger persistence and telemetry. Never throws for - * a verify failure — every rejection is a rolled-back fallback. */ + * a verify failure — every rejection is a fallback. */ function completeGrant( attempt: GrantAttempt, result: CodexHookTrustGrantSessionResult ): CodexManagedTrustGrantOutcome { - const { plan, expected, hostKey, configSnapshot } = attempt + const { plan, expected, hostKey } = attempt const rejectGrant = ( detail: unknown, verifyClass: CodexTrustGrantVerifyClass ): CodexManagedTrustGrantOutcome => { - restoreCodexTrustConfig(plan.tomlPath, configSnapshot) - startTransientCooldown(hostKey) + if (!plan.background) { + startCodexTrustGrantCooldown(hostKey) + } return fallback(plan, 'verify-failed', detail, verifyClass) } if (result.outcome === 'verify-failed') { @@ -171,7 +160,7 @@ function completeGrant( if (seenNormalizedKeys.size !== expected.length) { return rejectGrant('granted entry set did not cover expected entries', 'coverage') } - transientRetryAfterByHost.delete(hostKey) + clearCodexTrustGrantCooldown(hostKey) try { writeCodexTrustGrantLedgerHome(plan.runtimeHomePath, { binary: attempt.currentStamp, @@ -198,25 +187,30 @@ async function runGrantAttempt( plan: CodexManagedTrustGrantPlan, expected: ExpectedManagedEntry[], resolvedHost: ResolvedCodexTrustGrantHost, - hostKey: CodexAppServerHostKey + hostKey: CodexAppServerHostKey, + beforeSession: (() => void) | undefined ): Promise { - // Why: the RPC may rewrite config.toml before a later RPC fails. Restore its - // exact pre-session bytes before the legacy lane runs so every fallback has - // the same input and output as the pre-RPC implementation. + // Why no config.toml restore on failure: the session writes trust only at + // Orca's own keys, bound to Orca's command by its hash, and each caller settles + // those keys itself. A restore would undo anything saved meanwhile. const attempt: GrantAttempt = { plan, expected, hostKey, currentStamp: resolvedHost.binaryStamp, - configSnapshot: captureCodexTrustConfig(plan.tomlPath), startedAtMs: Date.now() } let unsupportedError: unknown + // Why unshared: a launch's inline grant must not wait behind a background + // session that may take a cold app-server's full budget. + const runWithCapability = plan.background + ? codexAppServerCapabilityCache.runUnshared.bind(codexAppServerCapabilityCache) + : codexAppServerCapabilityCache.runWithFallback.bind(codexAppServerCapabilityCache) try { - return await codexAppServerCapabilityCache.runWithFallback( + return await runWithCapability( hostKey, async () => { - removeSelfComputedTrustBeforeGrant(plan) + beforeSession?.() return completeGrant( attempt, await runSession( @@ -224,7 +218,8 @@ async function runGrantAttempt( runtimeHomePath: plan.runtimeHomePath, managedCommand: plan.managedCommand, expectedTrustKeys: expected.map(({ normalizedKey }) => normalizedKey), - useDefaultCodexHome: plan.useDefaultCodexHome + useDefaultCodexHome: plan.useDefaultCodexHome, + ...(plan.background ? { timeoutMs: CODEX_BACKGROUND_TRUST_GRANT_TIMEOUT_MS } : {}) }) ) ) @@ -232,11 +227,10 @@ async function runGrantAttempt( async () => { if (unsupportedError === undefined) { // Why: a concurrent launch's probe proved the surface missing while - // this one waited behind it; nothing was mutated, so nothing to undo. + // this one waited behind it. return fallback(plan, 'unsupported-cached') } - restoreCodexTrustConfig(plan.tomlPath, attempt.configSnapshot) - transientRetryAfterByHost.delete(hostKey) + clearCodexTrustGrantCooldown(hostKey) return fallback(plan, 'unsupported', unsupportedError) }, (error) => { @@ -248,22 +242,43 @@ async function runGrantAttempt( } ) } catch (error) { - restoreCodexTrustConfig(plan.tomlPath, attempt.configSnapshot) - startTransientCooldown(hostKey) + if (!plan.background) { + startCodexTrustGrantCooldown(hostKey) + } return fallback(plan, 'error', error) } } +/** + * The session-free half of a grant: Codex's recorded hashes for these entries + * when the ledger shows they are still current, or null. + */ +export async function findCurrentManagedCodexHookTrust( + plan: CodexManagedTrustGrantPlan +): Promise { + try { + if (process.env[DISABLE_ENV_FLAG] === '1' || plan.managedEntries.length === 0) { + return null + } + const resolvedHost = await resolveCodexTrustGrantHost(plan.host) + return findLedgerGrant(plan, buildExpectedEntries(plan), resolvedHost.binaryStamp) + } catch { + return null + } +} + /** * Grants trust for Orca's managed Codex hooks through codex's own app-server * RPCs, verified by re-list. Returns the granted entries carrying Codex's - * verbatim hashes, or a fallback marker — the caller then runs the previous - * computeTrustedHash lane, byte-identical to the pre-RPC behavior. Never + * verbatim hashes, or a fallback marker — a managed-home caller then writes + * computeTrustedHash trust, and the real-home caller withdraws its entry. Never * throws: any unexpected failure is a fallback, because hook install is - * best-effort launch prep. + * best-effort launch prep. `beforeSession` runs, under the caller's lane, only + * when a session will run: never on a ledger hit, cooldown or cached fallback. */ export async function grantManagedCodexHookTrust( - plan: CodexManagedTrustGrantPlan + plan: CodexManagedTrustGrantPlan, + beforeSession?: () => void ): Promise { try { if (process.env[DISABLE_ENV_FLAG] === '1') { @@ -288,16 +303,12 @@ export async function grantManagedCodexHookTrust( if (!codexAppServerCapabilityCache.shouldTry(hostKey)) { return fallback(plan, 'unsupported-cached') } - const transientRetryAfter = transientRetryAfterByHost.get(hostKey) - if (transientRetryAfter !== undefined) { - if (Date.now() < transientRetryAfter) { - return fallback(plan, 'retry-cached') - } - transientRetryAfterByHost.delete(hostKey) + if (!plan.background && isCodexTrustGrantCoolingDown(hostKey)) { + return fallback(plan, 'retry-cached') } - return await runExclusivelyForCodexTrustConfig(plan.tomlPath, () => - runGrantAttempt(plan, expected, resolvedHost, hostKey) - ) + // Why no lane across the session: Codex writes its own records, and a held + // lane would queue every launch's config.toml write behind a cold app-server. + return await runGrantAttempt(plan, expected, resolvedHost, hostKey, beforeSession) } catch (error) { return fallback(plan, 'error', error) } @@ -313,9 +324,9 @@ export const _internals = { diagnostics.fellBack = 0 diagnostics.verifyFailed = 0 diagnostics.lastFallbackReason = null - transientRetryAfterByHost.clear() + resetCodexTrustGrantCooldowns() }, transientCooldownCountForTests(): number { - return transientRetryAfterByHost.size + return countCodexTrustGrantCooldowns() } } diff --git a/src/main/codex/codex-hook-trust-queue.ts b/src/main/codex/codex-hook-trust-queue.ts index 58213071a2d0..c4d459374663 100644 --- a/src/main/codex/codex-hook-trust-queue.ts +++ b/src/main/codex/codex-hook-trust-queue.ts @@ -3,9 +3,9 @@ import { getCodexConfigTomlPath, getSystemCodexConfigTomlPath } from './codex-ho // Why (#16441): these sequences mutate the runtime config.toml *and* the // system one — approval promotion, the system-config sync and the legacy sweep -// all touch ~/.codex/config.toml — so holding only the runtime lane still lets -// a real-home grant's capture->restore window swallow their writes. Lock order -// is always runtime-before-system; every other holder acquires it that way too. +// all touch ~/.codex/config.toml — so they hold both lanes across their awaits. +// Lock order is always runtime-before-system; every other holder acquires it +// that way too. export function runExclusivelyForRuntimeAndSystemTrustConfig( runtimeHomePath: string, run: () => Promise diff --git a/src/main/codex/codex-hook-wsl-runtime.ts b/src/main/codex/codex-hook-wsl-runtime.ts index 03a893880dae..e2318e30b96f 100644 --- a/src/main/codex/codex-hook-wsl-runtime.ts +++ b/src/main/codex/codex-hook-wsl-runtime.ts @@ -20,7 +20,11 @@ import { wrapReadablePosixHookCommand, writeCodexHooksJson } from './codex-hook-definition' -import { grantManagedCodexHookTrust } from './codex-hook-trust-grant' +import { + grantManagedCodexHookTrust, + type CodexManagedTrustGrantPlan +} from './codex-hook-trust-grant' +import { removeSelfComputedTrustBeforeGrant } from './codex-managed-trust-grant-plan' import { getManagedScript } from './codex-hook-script' import { removeStaleWslRuntimeManagedHookTrustEntries, @@ -33,9 +37,9 @@ import type { WslCanonicalPathSettlement } from './codex-wsl-hook-install-plan' -// Why (#16441): the grant inside awaits a codex app-server session, so a -// concurrent pane launch could write this config.toml between this run's -// capture and its restore. One lane per file keeps the sequence atomic. +// Why (#16441): this run clears Orca's computed trust, awaits Codex's grant, and +// may write computed trust back; a concurrent pane launch must not interleave +// with that sequence. One lane per file keeps it atomic. export function installManagedHooksIntoWslRuntime( plan: CodexWslRuntimeHookInstallPlan ): Promise { @@ -109,14 +113,18 @@ async function installManagedHooksIntoWslRuntimeExclusively( trustEntries, previousLedgerHome ? [previousLedgerHome] : [] ) - const grant = await grantManagedCodexHookTrust({ + const grantPlan: CodexManagedTrustGrantPlan = { runtimeHomePath, tomlPath: plan.tomlPath, managedCommand: command, managedEntries: trustEntries, host: { kind: 'wsl', distro: plan.wslDistro, linuxRuntimeHome: plan.linuxRuntimeHome }, telemetryLane: 'managed' - }) + } + // Why: the fallback below writes this trust back if the session fails. + const grant = await grantManagedCodexHookTrust(grantPlan, () => + removeSelfComputedTrustBeforeGrant(grantPlan) + ) if (grant.lane === 'fallback') { // Why: WSL runtime homes may carry user hook approvals we did not rebuild // here; only upsert Orca's entries instead of sweeping the whole source. diff --git a/src/main/codex/codex-item-run-state.ts b/src/main/codex/codex-item-run-state.ts new file mode 100644 index 000000000000..a3b22eff8aed --- /dev/null +++ b/src/main/codex/codex-item-run-state.ts @@ -0,0 +1,16 @@ +import { readString } from './codex-item-field-readers' +import type { CodexThreadItem } from './codex-thread-item-identity' + +/** Codex reports `inProgress` then a terminal status; a zero exit code is the + * only thing that makes a finished command a success. */ +export function codexItemRunState(item: CodexThreadItem): 'running' | 'completed' | 'failed' { + const status = readString(item, 'status') + if (status === null || status === 'inProgress') { + return 'running' + } + if (status !== 'completed') { + return 'failed' + } + const exitCode = item.exitCode + return typeof exitCode === 'number' && exitCode !== 0 ? 'failed' : 'completed' +} diff --git a/src/main/codex/codex-managed-trust-grant-plan.ts b/src/main/codex/codex-managed-trust-grant-plan.ts index e816b0cbff53..8e3749c62f08 100644 --- a/src/main/codex/codex-managed-trust-grant-plan.ts +++ b/src/main/codex/codex-managed-trust-grant-plan.ts @@ -27,6 +27,8 @@ export type CodexManagedTrustGrantPlan = { telemetryLane: CodexTrustGrantTelemetryLane /** Match a pane where CODEX_HOME is absent instead of an explicit managed home. */ useDefaultCodexHome?: boolean + /** Off the launch path: a cold-start budget, and no cooldown; the caller schedules retries. */ + background?: boolean } export type ExpectedManagedEntry = { @@ -44,7 +46,9 @@ export function buildExpectedEntries(plan: CodexManagedTrustGrantPlan): Expected } /** Windows fallback writes equivalent separator variants that Codex's canonical - * RPC key may not overwrite, leaving conflicting logical trust behind. */ + * RPC key may not overwrite, leaving conflicting logical trust behind. Only for a + * home whose fallback writes that trust back: in ~/.codex a matching record is + * Codex's own, and nothing would restore it. */ export function removeSelfComputedTrustBeforeGrant(plan: CodexManagedTrustGrantPlan): void { const trustStates = readHookTrustEntries(plan.tomlPath) const ownedKeys = plan.managedEntries diff --git a/src/main/codex/codex-provider-retry-idle-sweep.test.ts b/src/main/codex/codex-provider-retry-idle-sweep.test.ts new file mode 100644 index 000000000000..b1e8b8b6510c --- /dev/null +++ b/src/main/codex/codex-provider-retry-idle-sweep.test.ts @@ -0,0 +1,139 @@ +// A Codex that keeps reconnecting is working, so the idle sweep must not stop it. Every retry +// frame writes and publishes its own row, and that publish is the activity the sweep reads. +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { openTestAgentSessionRecordStore } from '../runtime/agent-session-record-store-test-harness' +import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { STRUCTURED_AGENT_SESSION_IDLE_MS } from '../native-chat/agent-session-wire/structured-agent-session-idle-sweep' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + resetHostTestOperationIds +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' + +const SWEEP_MS = 5 +const RETRY_GAP_MS = 10 * 60_000 + +let root: string +let host: StructuredAgentSessionHost +let sink: StructuredAgentSessionEventSink | null +let closeSession: Mock> +let clock: number + +function streamRetry(attempt: number): Record { + return { + threadId: THREAD, + turnId: 'turn-1', + willRetry: true, + error: { + message: `Reconnecting... ${attempt}/5`, + codexErrorInfo: { responseStreamDisconnected: { httpStatusCode: 502 } } + } + } +} + +/** Long enough for many sweep ticks, so "still open" means the sweep declined. */ +function waitOutSeveralSweeps(): Promise { + return new Promise((resolve) => setTimeout(resolve, SWEEP_MS * 20)) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-codex-retry-sweep-')) + resetHostTestOperationIds() + sink = null + clock = NOW + closeSession = vi.fn(async () => true) + const store = await openTestAgentSessionRecordStore(root) + const adapter: StructuredAgentSessionAdapter = { + acquire: async ({ fence, spawnToken, events }) => { + sink = events ?? null + return { + process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW - 1_000, spawnToken }, + acquisitionGeneration: 'generation-1', + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + } + }, + closeSession, + releaseAcquisition: async () => true, + dispatch: async () => ({ state: 'admitted' }), + cancelTurn: async () => ({ cancelled: false }), + answerPrompt: async () => undefined, + setOption: async () => undefined + } + host = new StructuredAgentSessionHost({ + store, + adapter, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-1', + idleSweep: { intervalMs: SWEEP_MS, idleMs: STRUCTURED_AGENT_SESSION_IDLE_MS }, + now: () => clock + }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('a Codex reconnecting a dropped stream', () => { + it('keeps the conversation open past the idle window while retry frames arrive', async () => { + expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({ + ok: true + }) + if (!sink) { + throw new Error('the host never handed the provider its event sink') + } + const translator = createCodexJournalTranslator({ sink, primaryThreadId: () => THREAD }) + + // Five frames ten minutes apart: fifty minutes, well past the thirty-minute idle window. + for (let attempt = 1; attempt <= 5; attempt += 1) { + clock += RETRY_GAP_MS + translator.handle({ + type: 'notification', + sessionId: SESSION, + threadId: THREAD, + method: 'error', + params: streamRetry(attempt) + }) + await waitOutSeveralSweeps() + expect(closeSession).not.toHaveBeenCalled() + expect(host.hasSession(SESSION)).toBe(true) + } + + const items = host['sessions'].get(SESSION)?.journal.snapshot().items ?? [] + const retryRows = items.filter( + (item) => item.body.kind === 'status' && item.body.failure?.kind === 'providerRetrying' + ) + expect(retryRows.map((row) => row.body)).toEqual( + [1, 2, 3, 4, 5].map((attempt) => + expect.objectContaining({ + failure: expect.objectContaining({ + detail: expect.objectContaining({ text: `Reconnecting... ${attempt}/5` }) + }) + }) + ) + ) + + // Once the frames stop, the same clock does let the sweep close it. + clock += STRUCTURED_AGENT_SESSION_IDLE_MS + await vi.waitFor(() => { + expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict') + expect(host.hasSession(SESSION)).toBe(false) + }) + }) +}) diff --git a/src/main/codex/codex-provider-retry-row.test.ts b/src/main/codex/codex-provider-retry-row.test.ts new file mode 100644 index 000000000000..7a49e60f279f --- /dev/null +++ b/src/main/codex/codex-provider-retry-row.test.ts @@ -0,0 +1,233 @@ +// A Codex stream error it is about to retry is a warning row per attempt, never a red row: the +// journal keeps every attempt, and the transcript draws only the latest of a run. +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import { adapterFor, fakeCodex, identityFor } from './codex-structured-session-adapter-fixture' +import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' + +const THREAD_ID = 'thread-abc' +const TURN_ID = 'turn-1' +const CAPTURED_THREAD = '00000000-0000-7000-8000-000000000012' + +type Row = { key: string; body: AgentJournalItemBody } + +function harness() { + const rows: Row[] = [] + let publishes = 0 + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity: AgentJournalItemIdentity, body) => + rows.push({ key: agentJournalItemKey(identity), body }), + appendTombstone: () => undefined, + publish: () => { + publishes += 1 + } + } + const translator = createCodexJournalTranslator({ sink, primaryThreadId: () => THREAD_ID }) + translator.handle(notification('turn/started', { turn: { id: TURN_ID } })) + return { translator, rows, publishes: () => publishes } +} + +/** Latest body per identity, in first-seen order: what the journal reducer keeps. */ +function reduced(rows: readonly Row[]): Row[] { + const latest = new Map() + for (const row of rows) { + latest.set(row.key, row) + } + return [...latest.values()] +} + +function notification(method: string, params: unknown): CodexStructuredSessionEvent { + return { type: 'notification', sessionId: 'session-1', threadId: THREAD_ID, method, params } +} + +/** The params the app server sends for a stream error it is about to retry. */ +function retryParams(message: string, additionalDetails?: string) { + return { + threadId: THREAD_ID, + turnId: TURN_ID, + willRetry: true, + error: { + message, + codexErrorInfo: { responseStreamDisconnected: { httpStatusCode: 502 } }, + ...(additionalDetails !== undefined ? { additionalDetails } : {}) + } + } +} + +function retrying(message: string, additionalDetails?: string) { + return notification('error', retryParams(message, additionalDetails)) +} + +function retryRows(rows: readonly Row[]): Row[] { + return reduced(rows).filter( + (row) => row.body.kind === 'status' && row.body.failure?.kind === 'providerRetrying' + ) +} + +describe('a Codex stream error it is about to retry', () => { + it('writes its own warning row for every attempt', () => { + const { translator, rows } = harness() + + for (let attempt = 1; attempt <= 3; attempt += 1) { + translator.handle(retrying(`Reconnecting... ${attempt}/5`, 'stream disconnected')) + } + + const written = retryRows(rows) + expect(written).toHaveLength(3) + expect(new Set(written.map((row) => row.key)).size).toBe(3) + expect(written.map((row) => row.body.kind === 'status' && row.body.text)).toEqual([ + 'Codex is retrying: Reconnecting... 1/5.\nstream disconnected', + 'Codex is retrying: Reconnecting... 2/5.\nstream disconnected', + 'Codex is retrying: Reconnecting... 3/5.\nstream disconnected' + ]) + expect(written[2]?.body).toEqual({ + kind: 'status', + tone: 'warning', + text: 'Codex is retrying: Reconnecting... 3/5.\nstream disconnected', + failure: { + kind: 'providerRetrying', + detail: { text: 'Reconnecting... 3/5', audience: 'person' }, + retry: { error: 'responseStreamDisconnected', status: 502, cause: 'stream disconnected' } + }, + providerFrame: { + provider: 'codex', + kind: 'notification:error', + payload: expect.objectContaining({ head: expect.stringContaining('Reconnecting... 3/5') }) + } + }) + // No row revises another: each attempt was written once. + expect(rows.filter((row) => written.some((retry) => retry.key === row.key))).toHaveLength(3) + expect( + reduced(rows).filter((row) => row.body.kind === 'status' && row.body.tone === 'error') + ).toEqual([]) + }) + + it('is one line when Codex gives no detail, or only repeats its message', () => { + const { translator, rows } = harness() + translator.handle(retrying('Reconnecting... 1/5')) + translator.handle(retrying('Reconnecting... 2/5', 'Reconnecting... 2/5')) + + expect(retryRows(rows).map((row) => row.body.kind === 'status' && row.body.text)).toEqual([ + 'Codex is retrying: Reconnecting... 1/5.', + 'Codex is retrying: Reconnecting... 2/5.' + ]) + }) + + it('publishes every attempt, so each one renews the idle clock', () => { + const { translator, publishes } = harness() + + const before = publishes() + translator.handle(retrying('Reconnecting... 1/5')) + translator.handle(retrying('Reconnecting... 2/5')) + translator.handle(retrying('Reconnecting... 3/5')) + + expect(publishes() - before).toBe(3) + }) + + it('keeps every earlier row when a new connection in the same session retries', () => { + const rows: Row[] = [] + const connect = (acquisitionId: string) => { + const translator = createCodexJournalTranslator({ + sink: { + appendItem: (identity, body) => rows.push({ key: agentJournalItemKey(identity), body }), + appendTombstone: () => undefined, + publish: () => undefined + }, + primaryThreadId: () => THREAD_ID, + acquisitionId + }) + translator.handle(notification('turn/started', { turn: { id: TURN_ID } })) + return translator + } + const first = connect('generation-1') + first.handle(retrying('Reconnecting... 1/5')) + first.handle(retrying('Reconnecting... 2/5')) + const second = connect('generation-2') + second.handle(retrying('Reconnecting... 1/5')) + + // Each write is a new row: nothing the first connection wrote is revised. + expect(retryRows(rows).map((row) => row.body.kind === 'status' && row.body.text)).toEqual([ + 'Codex is retrying: Reconnecting... 1/5.', + 'Codex is retrying: Reconnecting... 2/5.', + 'Codex is retrying: Reconnecting... 1/5.' + ]) + }) + + it('is named for the acquisition that received it, so a reconnect writes new rows', async () => { + const codex = fakeCodex() + const adapter = adapterFor(codex) + const rows: Row[] = [] + const events: StructuredAgentSessionEventSink = { + appendItem: (identity, body) => rows.push({ key: agentJournalItemKey(identity), body }), + appendTombstone: () => undefined, + publish: () => undefined + } + for (const fence of [7, 8]) { + await adapter.acquire({ + identity: identityFor('session-1'), + fence, + spawnToken: `spawn-${fence}`, + events + }) + codex.connections + .at(-1) + ?.handlers.onNotification?.('error', retryParams('Reconnecting... 1/5')) + } + + expect(codex.connections).toHaveLength(2) + expect(retryRows(rows)).toHaveLength(2) + }) + + it('leaves the error Codex gives up on as the red row, and the turn fails, in a captured run', () => { + const captured = readFileSync( + join(__dirname, '__fixtures__', 'codex-app-server-turn-endings.jsonl'), + 'utf8' + ) + .split('\n') + .filter((line) => line.length > 0) + .map((line): { case: string; method: string; params: unknown } => JSON.parse(line)) + .filter((frame) => frame.case === '0.141.0-conn-refused') + const rows: Row[] = [] + const translator = createCodexJournalTranslator({ + sink: { + appendItem: (identity, body) => rows.push({ key: agentJournalItemKey(identity), body }), + appendTombstone: () => undefined, + publish: () => undefined + }, + primaryThreadId: () => CAPTURED_THREAD + }) + for (const frame of captured) { + translator.handle({ + type: 'notification', + sessionId: 'session-1', + threadId: CAPTURED_THREAD, + method: frame.method, + params: frame.params + }) + } + + const cause = + 'stream disconnected before completion: error sending request for url (http://127.0.0.1:9/v1/responses)' + const errorFrameRows = reduced(rows).flatMap((row) => + row.body.kind === 'status' && row.body.providerFrame?.kind === 'notification:error' + ? [{ tone: row.body.tone, text: row.body.text }] + : [] + ) + expect(errorFrameRows).toEqual([ + { tone: 'warning', text: `Codex is retrying: Reconnecting... 1/2.\n${cause}` }, + { tone: 'warning', text: `Codex is retrying: Reconnecting... 2/2.\n${cause}` }, + { tone: 'error', text: cause } + ]) + expect(reduced(rows).map((row) => row.body)).toContainEqual( + expect.objectContaining({ kind: 'turn', state: 'completed', outcome: 'failure' }) + ) + }) +}) diff --git a/src/main/codex/codex-provider-retry-row.ts b/src/main/codex/codex-provider-retry-row.ts new file mode 100644 index 000000000000..48b0c8eef40b --- /dev/null +++ b/src/main/codex/codex-provider-retry-row.ts @@ -0,0 +1,62 @@ +// The row a Codex stream error it is about to retry writes: Codex's own progress sentence, what +// failed on the line under it, and a `providerRetrying` fact. Every attempt is its own row; the +// transcript draws only the latest of a run. + +import { + agentSessionFailureFact, + providerDiagnostic, + readProviderRetry +} from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import type { AgentJournalStatusItem } from '../../shared/agent-session-journal-types' +import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' +import { + boundPayload, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' +import { + readCodexErrorAdditionalDetails, + readCodexErrorInfo, + readCodexErrorMessage, + readCodexErrorWillRetry +} from './codex-structured-thread-facts' + +/** An `error` frame for a stream error Codex is about to retry; it ends nothing. */ +export function isCodexProviderRetryFrame(event: CodexStructuredSessionEvent): boolean { + return ( + event.type === 'notification' && + event.method === 'error' && + readCodexErrorWillRetry(event.params) + ) +} + +export function codexProviderRetryRowBody(payload: unknown): AgentJournalStatusItem { + const message = readCodexErrorMessage(payload) + const detail = message ? providerDiagnostic(message, 'person') : undefined + const additionalDetails = readCodexErrorAdditionalDetails(payload) + const retry = readProviderRetry({ + ...readCodexErrorInfo(payload), + // Details that only repeat the message would print the same words twice. + ...(additionalDetails && additionalDetails.trim() !== message?.trim() + ? { cause: additionalDetails } + : {}) + }) + const words = agentSessionFailureWords( + agentSessionFailureFact('providerRetrying', { + ...(detail ? { detail } : {}), + ...(retry ? { retry } : {}) + }), + { surface: 'row', agentName: TUI_AGENT_DISPLAY_NAMES.codex } + ) + return { + kind: 'status', + tone: 'warning', + ...words, + providerFrame: { + provider: 'codex', + kind: 'notification:error', + payload: boundPayload(JSON.stringify(payload), DEFAULT_JOURNAL_PAYLOAD_LIMITS) + } + } +} diff --git a/src/main/codex/codex-real-home-background-grant.ts b/src/main/codex/codex-real-home-background-grant.ts new file mode 100644 index 000000000000..721ee9691046 --- /dev/null +++ b/src/main/codex/codex-real-home-background-grant.ts @@ -0,0 +1,121 @@ +import { readHooksJsonWithRaw } from '../agent-hooks/installer-utils' +import { getRealHomeConfigTomlPath, getRealHomeHooksJsonPath } from './codex-real-home-hooks-json' +import { + grantManagedCodexHookTrust, + type CodexManagedTrustGrantOutcome, + type CodexManagedTrustGrantPlan +} from './codex-hook-trust-grant' +import { createCodexHookTrustEntry } from './codex-hook-identity' +import { readOrcaEntryTrust } from './codex-real-home-entry-trust' +import type { RealHomeCodexHookSlotWrite } from './codex-real-home-hook-entry-plan' +import { readHookTrustEntries } from './config-toml-trust' + +// Why seconds: a background grant blocks no launch, and a long latch at boot +// keeps ~/.codex off its hooks long after the app-server recovers. +const CODEX_BACKGROUND_TRUST_GRANT_RETRY_INTERVAL_MS = 10_000 +// Why: a host whose app-server never starts in time must not rewrite +// ~/.codex/hooks.json and start a 30 s session on every launch for good. +const TIMEOUTS_BEFORE_BACKOFF = 3 +const TIMEOUT_BACKOFF_MS = [10_000, 60_000, 5 * 60_000] +// Why process-scoped, reset only by a success: app start begins at zero, so a slow boot never latches. +let consecutiveTimeouts = 0 + +export type RealHomeBackgroundGrant = { + plan: CodexManagedTrustGrantPlan + writes: readonly RealHomeCodexHookSlotWrite[] + command: string +} + +/** Codex's approval of real-home entries; null when the attempt threw. Never throws. */ +export async function requestRealHomeCodexApproval( + plan: CodexManagedTrustGrantPlan +): Promise { + try { + return await grantManagedCodexHookTrust(plan) + } catch (error) { + console.warn('[codex-real-home-hooks] background trust grant failed:', error) + return null + } +} + +/** Records an approval's outcome and returns when the next attempt may run. */ +export function recordRealHomeApprovalOutcome( + outcome: CodexManagedTrustGrantOutcome | null +): number { + if (outcome?.lane === 'rpc') { + consecutiveTimeouts = 0 + return 0 + } + if ( + outcome?.reason === 'unsupported' || + outcome?.reason === 'unsupported-cached' || + outcome?.reason === 'disabled' + ) { + return Number.POSITIVE_INFINITY + } + if (outcome?.errorClass !== 'timeout') { + return Date.now() + CODEX_BACKGROUND_TRUST_GRANT_RETRY_INTERVAL_MS + } + // Why: the first slow cold starts retry on the next launch instead of latching for minutes. + consecutiveTimeouts += 1 + if (consecutiveTimeouts < TIMEOUTS_BEFORE_BACKOFF) { + return 0 + } + const step = Math.min( + consecutiveTimeouts - TIMEOUTS_BEFORE_BACKOFF, + TIMEOUT_BACKOFF_MS.length - 1 + ) + return Date.now() + TIMEOUT_BACKOFF_MS[step] +} + +export function describeRealHomeApprovalRetry(retryAfterMs: number): string { + if (retryAfterMs === Number.POSITIVE_INFINITY) { + return 'not retrying' + } + const delayMs = retryAfterMs - Date.now() + return delayMs > 0 ? `retrying in ${Math.ceil(delayMs / 1000)} s` : 'retrying on the next launch' +} + +/** + * Whether ~/.codex holds an entry with `command` that Codex would put up for + * review. An unreadable file reads as yes: the caller's wait is bounded. + */ +export function hasUnapprovedRealHomeOrcaEntry(command: string): boolean { + try { + const hooksJsonPath = getRealHomeHooksJsonPath() + const { config } = readHooksJsonWithRaw(hooksJsonPath) + const trustStates = readHookTrustEntries(getRealHomeConfigTomlPath()) + return Object.entries(config?.hooks ?? {}).some( + ([eventName, definitions]) => + Array.isArray(definitions) && + definitions.some( + (definition, groupIndex) => + Array.isArray(definition.hooks) && + definition.hooks.some((hook, handlerIndex) => { + if (hook.command !== command) { + return false + } + const entry = createCodexHookTrustEntry( + hooksJsonPath, + eventName, + groupIndex, + handlerIndex, + definition, + hook + ) + const trust = entry ? readOrcaEntryTrust(entry, trustStates) : 'untrusted' + return trust === 'untrusted' || trust === 'stale' + }) + ) + ) + } catch (error) { + console.warn('[codex-real-home-hooks] could not read Orca entry approval:', error) + return true + } +} + +export const _internals = { + resetTimeoutStreakForTesting(): void { + consecutiveTimeouts = 0 + } +} diff --git a/src/main/codex/codex-real-home-entry-trust.test.ts b/src/main/codex/codex-real-home-entry-trust.test.ts new file mode 100644 index 000000000000..91b628196c27 --- /dev/null +++ b/src/main/codex/codex-real-home-entry-trust.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { readOrcaEntryTrust } from './codex-real-home-entry-trust' +import { + computeTrustedHash, + readHookTrustEntriesFromContent, + upsertHookTrustEntriesInContent, + type CodexTrustEntry +} from './config-toml-trust' + +const ENTRY: CodexTrustEntry = { + sourcePath: '/home/jin/.codex/hooks.json', + eventLabel: 'stop', + groupIndex: 1, + handlerIndex: 0, + command: ': orca-agent-hook-form=1; /bin/sh "${HOME-}/.orca/agent-hooks/codex-hook.sh"', + timeoutSec: 10 +} + +function trustAfter( + entries: CodexTrustEntry[] +): ReturnType { + return readHookTrustEntriesFromContent(upsertHookTrustEntriesInContent('', entries)) +} + +describe('readOrcaEntryTrust', () => { + it('reads a present entry with the current hash as trusted', () => { + expect(readOrcaEntryTrust(ENTRY, trustAfter([ENTRY]))).toBe('trusted') + }) + + it('reads an entry with no trust block as untrusted', () => { + expect(readOrcaEntryTrust(ENTRY, trustAfter([]))).toBe('untrusted') + }) + + it('reads a hash for another command at the same key as stale', () => { + const trust = trustAfter([ + { ...ENTRY, trustedHash: computeTrustedHash({ ...ENTRY, command: 'x' }) } + ]) + + expect(readOrcaEntryTrust(ENTRY, trust)).toBe('stale') + }) + + it('reads an entry the user turned off as disabled, whatever its hash', () => { + expect(readOrcaEntryTrust(ENTRY, trustAfter([{ ...ENTRY, enabled: false }]))).toBe('disabled') + }) +}) diff --git a/src/main/codex/codex-real-home-entry-trust.ts b/src/main/codex/codex-real-home-entry-trust.ts new file mode 100644 index 000000000000..defab2623521 --- /dev/null +++ b/src/main/codex/codex-real-home-entry-trust.ts @@ -0,0 +1,29 @@ +import { + computeTrustedHash, + computeTrustKey, + type CodexHookTrustState, + type CodexTrustEntry +} from './config-toml-trust' + +/** + * How Codex will treat an Orca entry this install wrote: + * - 'trusted': the stored hash matches the entry; + * - 'untrusted': no stored hash, so Codex lists it for review; + * - 'stale': a stored hash for other content at that slot; Codex lists it as modified; + * - 'disabled': the user turned it off. + */ +export type OrcaEntryTrust = 'trusted' | 'untrusted' | 'stale' | 'disabled' + +export function readOrcaEntryTrust( + entry: CodexTrustEntry, + trustStates: ReadonlyMap +): OrcaEntryTrust { + const state = trustStates.get(computeTrustKey(entry)) + if (state?.enabled === false) { + return 'disabled' + } + if (state?.trustedHash === undefined) { + return 'untrusted' + } + return state.trustedHash === computeTrustedHash(entry) ? 'trusted' : 'stale' +} diff --git a/src/main/codex/codex-real-home-failed-grant-concurrent-edit.test.ts b/src/main/codex/codex-real-home-failed-grant-concurrent-edit.test.ts new file mode 100644 index 000000000000..31a0710fafb5 --- /dev/null +++ b/src/main/codex/codex-real-home-failed-grant-concurrent-edit.test.ts @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import type * as Os from 'node:os' +import { join } from 'node:path' +import { wrapPosixHookCommand, type HookDefinition } from '../agent-hooks/installer-utils' +import { _internals as grantInternals } from './codex-hook-trust-grant' +import { createCodexHookTrustEntry } from './codex-hook-identity' +import { + computeTrustKey, + computeTrustedHash, + readHookTrustEntries, + upsertHookTrustEntries, + type CodexTrustEntry +} from './config-toml-trust' +import { getCodexManagedHookInstallMaterial } from './codex-hook-definition' +import { isCodexManagedCommand, setupCodexHookHomes } from './hook-service-test-harness' + +const { getPathMock, homedirMock, resolveCodexCommandMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>(), + homedirMock: vi.fn<() => string>(), + resolveCodexCommandMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ app: { getPath: getPathMock } })) +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, homedir: homedirMock } +}) +vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock })) + +import { + _internals as realHomeInternals, + ensureRealHomeCodexHookState as startRealHomeCodexHookEnsure +} from './codex-real-home-hook-install' + +/** The lane once Codex's background approval, if any, has settled. */ +async function ensureRealHomeCodexHookState( + args: Parameters[0] +): ReturnType { + await startRealHomeCodexHookEnsure(args) + return realHomeInternals.settledVerdictForTesting() +} + +// Why this file (QA case 9): a trust session that fails while someone else edits +// ~/.codex must keep that edit in both files, and must leave no Orca entry that +// Codex would list as "needs review". + +const homes = setupCodexHookHomes(homedirMock, getPathMock) +afterEach(() => { + vi.useRealTimers() +}) +beforeEach(() => { + realHomeInternals.resetForTesting('pending') + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) +const USER_HOOK: HookDefinition = { hooks: [{ type: 'command', command: 'user-hook.sh' }] } +const SAVED_HOOK: HookDefinition = { hooks: [{ type: 'command', command: 'saved-meanwhile.sh' }] } +const SAVED_PROJECT = '\n[projects."/work/saved-meanwhile"]\ntrust_level = "trusted"\n' + +type HooksFile = { hooks: Record } + +function hooksPath(): string { + return join(homes.tmpHome, '.codex', 'hooks.json') +} + +function configPath(): string { + return join(homes.tmpHome, '.codex', 'config.toml') +} + +function readHooks(): HooksFile { + return JSON.parse(readFileSync(hooksPath(), 'utf-8')) +} + +function seed(file: HooksFile): void { + mkdirSync(join(homes.tmpHome, '.codex'), { recursive: true }) + writeFileSync(hooksPath(), `${JSON.stringify(file, null, 2)}\n`) + writeFileSync(configPath(), 'model = "user-model"\n') +} + +const SAVED_HOOK_HASH = 'sha256:approved-in-codex' + +function savedHookAt(groupIndex: number): CodexTrustEntry { + return { + sourcePath: hooksPath(), + eventLabel: 'stop', + groupIndex, + handlerIndex: 0, + command: 'saved-meanwhile.sh' + } +} + +/** Codex fails the session after another writer saved both files meanwhile. */ +function failSessionAfterConcurrentEdits(): { sessions: number } { + const counts = { sessions: 0 } + grantInternals.setGrantSessionRunner(async () => { + counts.sessions += 1 + const hooks = readHooks() + hooks.hooks.Stop = [...(hooks.hooks.Stop ?? []), SAVED_HOOK] + writeFileSync(hooksPath(), `${JSON.stringify(hooks, null, 2)}\n`) + appendFileSync(configPath(), SAVED_PROJECT) + // Why: the user approves the saved hook in Codex while the session runs. + upsertHookTrustEntries(configPath(), [ + { ...savedHookAt(hooks.hooks.Stop.length - 1), trustedHash: SAVED_HOOK_HASH } + ]) + throw new Error('codex app-server exited with code 1') + }) + return counts +} + +function untrustedOrcaHandlers(): string[] { + const trust = readHookTrustEntries(configPath()) + const untrusted: string[] = [] + for (const [event, definitions] of Object.entries(readHooks().hooks)) { + definitions.forEach((definition, groupIndex) => { + definition.hooks?.forEach((hook, handlerIndex) => { + if (!isCodexManagedCommand(hook.command)) { + return + } + const entry = createCodexHookTrustEntry( + hooksPath(), + event, + groupIndex, + handlerIndex, + definition, + hook + ) + if ( + !entry || + trust.get(computeTrustKey(entry))?.trustedHash !== computeTrustedHash(entry) + ) { + untrusted.push(`${event}:${groupIndex}:${handlerIndex}`) + } + }) + }) + } + return untrusted +} + +describe('a failed real-home trust session with a concurrent edit', () => { + it('keeps the concurrent edits to both files and leaves no untrusted Orca entry', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + resolveCodexCommandMock.mockReturnValue(process.execPath) + seed({ hooks: { Stop: [USER_HOOK] } }) + const counts = failSessionAfterConcurrentEdits() + + expect( + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: homes.userDataDir, + writePolicy: 'add-missing-only' + }) + ).toBe('unavailable') + + expect(readHooks().hooks.Stop).toEqual([USER_HOOK, SAVED_HOOK]) + expect(readFileSync(configPath(), 'utf-8')).toContain(SAVED_PROJECT) + expect(untrustedOrcaHandlers()).toEqual([]) + // Why: withdrawing Orca's entry moved the saved hook up a slot; its approval moved too. + const trust = readHookTrustEntries(configPath()) + expect(trust.get(computeTrustKey(savedHookAt(1)))?.trustedHash).toBe(SAVED_HOOK_HASH) + expect(trust.get(computeTrustKey(savedHookAt(2)))).toBeUndefined() + // Why: the log says what happened, including when the next try comes. + const events = getCodexManagedHookInstallMaterial().events.length + expect(vi.mocked(console.warn)).toHaveBeenCalledWith( + `[codex-real-home-hooks] Codex did not approve Orca's entry (error); withdrew ${events} ` + + 'unapproved entries this attempt added; managed lane kept, retrying in 10 s' + ) + + vi.setSystemTime(Date.now() + 10_001) + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: homes.userDataDir, + writePolicy: 'add-missing-only' + }) + expect(counts.sessions).toBe(2) + }) + + it.skipIf(process.platform === 'win32')( + 'puts back the older entry a failed app-start conversion replaced, keeping the edits', + async () => { + resolveCodexCommandMock.mockReturnValue(process.execPath) + const older = wrapPosixHookCommand( + join(homes.tmpHome, '.orca', 'agent-hooks', 'codex-hook.sh') + ) + const olderGroup: HookDefinition = { + hooks: [{ type: 'command', command: older, timeout: 10 }] + } + seed({ hooks: { Stop: [USER_HOOK, olderGroup] } }) + failSessionAfterConcurrentEdits() + + expect( + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: homes.userDataDir, + writePolicy: 'convert-older-forms' + }) + ).toBe('unavailable') + + expect(readHooks().hooks.Stop).toEqual([USER_HOOK, olderGroup, SAVED_HOOK]) + expect(readFileSync(configPath(), 'utf-8')).toContain(SAVED_PROJECT) + } + ) +}) diff --git a/src/main/codex/codex-real-home-frozen-entry.test.ts b/src/main/codex/codex-real-home-frozen-entry.test.ts new file mode 100644 index 000000000000..f370e1121b91 --- /dev/null +++ b/src/main/codex/codex-real-home-frozen-entry.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it, vi } from 'vitest' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import type * as Os from 'node:os' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { wrapPosixHookCommand, type HookDefinition } from '../agent-hooks/installer-utils' +import type { CodexHookTrustGrantRequest } from './codex-app-server-client' +import { _internals as grantInternals } from './codex-hook-trust-grant' +import { + computeTrustedHash, + normalizeHookTrustKeyForLookup, + parseTrustKey, + readHookTrustEntries, + upsertHookTrustEntries +} from './config-toml-trust' +import { setupCodexHookHomes } from './hook-service-test-harness' + +const { getPathMock, homedirMock, resolveCodexCommandMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>(), + homedirMock: vi.fn<() => string>(), + resolveCodexCommandMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ app: { getPath: getPathMock } })) +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, homedir: homedirMock } +}) +vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock })) + +import { + _internals as realHomeInternals, + ensureRealHomeCodexHookState as startRealHomeCodexHookEnsure, + type RealHomeCodexHookWritePolicy +} from './codex-real-home-hook-install' + +/** The lane once Codex's background approval, if any, has settled. */ +async function ensureRealHomeCodexHookState( + args: Parameters[0] +): ReturnType { + await startRealHomeCodexHookEnsure(args) + return realHomeInternals.settledVerdictForTesting() +} +import { getCodexManagedHookInstallMaterial } from './codex-hook-definition' + +// Why this file: every Orca instance and build on one HOME shares ~/.codex. The +// frozen command makes their bytes identical, launches only add a missing +// entry, and only app start converts an older build's entry, once. + +const homes = setupCodexHookHomes(homedirMock, getPathMock) +const USER_HOOK: HookDefinition = { hooks: [{ type: 'command', command: 'user-hook.sh' }] } + +type HooksFile = { hooks: Record } + +function hooksPath(): string { + return join(homes.tmpHome, '.codex', 'hooks.json') +} + +function configPath(): string { + return join(homes.tmpHome, '.codex', 'config.toml') +} + +function writeHooks(file: HooksFile): string { + mkdirSync(join(homes.tmpHome, '.codex'), { recursive: true }) + const raw = `${JSON.stringify(file, null, 2)}\n` + writeFileSync(hooksPath(), raw) + return raw +} + +function readHooks(): HooksFile { + return JSON.parse(readFileSync(hooksPath(), 'utf-8')) +} + +function fileIdentity(path: string): { raw: string; ino: number; mtimeMs: number } { + const stat = statSync(path) + return { raw: readFileSync(path, 'utf-8'), ino: stat.ino, mtimeMs: stat.mtimeMs } +} + +/** What the build before the frozen command wrote, appended after the user's hooks. */ +function olderBuildCommand(): string { + const script = join( + homes.tmpHome, + '.orca', + 'agent-hooks', + process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh' + ) + return process.platform === 'win32' ? script : wrapPosixHookCommand(script) +} + +function everyEvent(command: string): HooksFile { + const { events } = getCodexManagedHookInstallMaterial() + return { + hooks: Object.fromEntries( + events.map((event) => [ + event, + [USER_HOOK, { hooks: [{ type: 'command', command, timeout: 10 }] }] + ]) + ) + } +} + +/** Codex's own grant: trusts what it lists as untrusted, and counts real trust writes. */ +function installCodexLikeGrant(): { sessions: number; trustWrites: number } { + const counts = { sessions: 0, trustWrites: 0 } + grantInternals.setGrantSessionRunner(async (request: CodexHookTrustGrantRequest) => { + counts.sessions += 1 + const trust = readHookTrustEntries(configPath()) + const entries = request.expectedTrustKeys.map((key) => { + const entry = { ...parseTrustKey(key)!, command: request.managedCommand, timeoutSec: 10 } + return { key, entry, trustedHash: computeTrustedHash(entry) } + }) + const untrusted = entries.filter( + ({ key, trustedHash }) => trust.get(key)?.trustedHash !== trustedHash + ) + if (untrusted.length > 0) { + counts.trustWrites += 1 + upsertHookTrustEntries( + configPath(), + untrusted.map(({ entry, trustedHash }) => ({ ...entry, trustedHash })) + ) + } + return { + outcome: 'granted' as const, + wroteTrust: untrusted.length > 0, + entries: entries.map(({ key, trustedHash }) => ({ + key, + normalizedKey: normalizeHookTrustKeyForLookup(key), + trustedHash + })) + } + }) + return counts +} + +function ensure( + writePolicy: RealHomeCodexHookWritePolicy, + userDataPath = homes.userDataDir +): ReturnType { + return ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath, writePolicy }) +} + +describe('the frozen real-home Codex entry', () => { + it('converts an older build entry once at app start: one .bak and one trust write', async () => { + resolveCodexCommandMock.mockReturnValue(process.execPath) + const counts = installCodexLikeGrant() + const olderRaw = writeHooks(everyEvent(olderBuildCommand())) + const { command } = getCodexManagedHookInstallMaterial() + + expect(await ensure('convert-older-forms')).toBe('installed') + const converted = fileIdentity(hooksPath()) + expect(converted.raw).toBe(`${JSON.stringify(everyEvent(command), null, 2)}\n`) + expect(readFileSync(`${hooksPath()}.bak`, 'utf-8')).toBe(olderRaw) + + for (const policy of ['add-missing-only', 'add-missing-only', 'convert-older-forms'] as const) { + realHomeInternals.resetForTesting('pending') + expect(await ensure(policy)).toBe('installed') + } + + expect(fileIdentity(hooksPath())).toEqual(converted) + expect(readFileSync(`${hooksPath()}.bak`, 'utf-8')).toBe(olderRaw) + expect(counts).toEqual({ sessions: 1, trustWrites: 1 }) + }) + + it('writes identical bytes from a second Orca instance, which writes nothing', async () => { + resolveCodexCommandMock.mockReturnValue(process.execPath) + const counts = installCodexLikeGrant() + writeHooks({ hooks: { Stop: [USER_HOOK] } }) + expect(await ensure('convert-older-forms')).toBe('installed') + const first = fileIdentity(hooksPath()) + const secondUserData = mkdtempSync(join(tmpdir(), 'orca-codex-second-instance-')) + + try { + realHomeInternals.resetForTesting('pending') + expect(await ensure('convert-older-forms', secondUserData)).toBe('installed') + expect(await ensure('add-missing-only', secondUserData)).toBe('installed') + } finally { + rmSync(secondUserData, { recursive: true, force: true }) + } + + expect(fileIdentity(hooksPath())).toEqual(first) + expect(counts.trustWrites).toBe(1) + }) + + it("leaves an older build's entry on a launch, with no write and no trust session", async () => { + resolveCodexCommandMock.mockReturnValue(process.execPath) + const counts = installCodexLikeGrant() + writeHooks(everyEvent(olderBuildCommand())) + const before = fileIdentity(hooksPath()) + + expect(await ensure('add-missing-only')).toBe('installed') + + expect(fileIdentity(hooksPath())).toEqual(before) + expect(existsSync(`${hooksPath()}.bak`)).toBe(false) + expect(counts.sessions).toBe(0) + }) + + it('adds the entry on a launch only to the event that lost it', async () => { + resolveCodexCommandMock.mockReturnValue(process.execPath) + installCodexLikeGrant() + const { command } = getCodexManagedHookInstallMaterial() + const frozen = everyEvent(command) + frozen.hooks.Stop = [USER_HOOK] + writeHooks(frozen) + + expect(await ensure('add-missing-only')).toBe('installed') + + const after = readHooks() + expect(after.hooks.Stop).toEqual([ + USER_HOOK, + { hooks: [{ type: 'command', command, timeout: 10 }] } + ]) + for (const [event, definitions] of Object.entries(frozen.hooks)) { + if (event !== 'Stop') { + expect(after.hooks[event]).toEqual(definitions) + } + } + }) + + it.each(['add-missing-only', 'convert-older-forms'] as const)( + 'never rewrites a newer form or appends beside it (%s)', + async (policy) => { + resolveCodexCommandMock.mockReturnValue(process.execPath) + const counts = installCodexLikeGrant() + const newer = `: orca-agent-hook-form=2; /bin/sh "\${HOME-}/.orca/agent-hooks/${ + process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh' + }"` + writeHooks(everyEvent(newer)) + const before = fileIdentity(hooksPath()) + + expect(await ensure(policy)).toBe('installed') + + expect(fileIdentity(hooksPath())).toEqual(before) + expect(counts.sessions).toBe(0) + } + ) + + it.each(['add-missing-only', 'convert-older-forms'] as const)( + 'keeps Orca entries in events this build does not subscribe to (%s)', + async (policy) => { + resolveCodexCommandMock.mockReturnValue(process.execPath) + installCodexLikeGrant() + const { command } = getCodexManagedHookInstallMaterial() + const file = everyEvent(command) + const newerBuildEvent = [{ hooks: [{ type: 'command' as const, command, timeout: 10 }] }] + file.hooks.PreCompact = newerBuildEvent + writeHooks(file) + + expect(await ensure(policy)).toBe('installed') + + expect(readHooks().hooks.PreCompact).toEqual(newerBuildEvent) + } + ) +}) diff --git a/src/main/codex/codex-real-home-hook-entry-plan.ts b/src/main/codex/codex-real-home-hook-entry-plan.ts new file mode 100644 index 000000000000..1cb92ef7bf44 --- /dev/null +++ b/src/main/codex/codex-real-home-hook-entry-plan.ts @@ -0,0 +1,258 @@ +import { + buildManagedCommandHook, + type HookCommandConfig, + type HookDefinition +} from '../agent-hooks/installer-utils' +import type { CodexManagedHookInstallMaterial } from './codex-hook-definition' +import { CODEX_HOOK_COMMAND_FORM, readCodexHookCommandForm } from './codex-hook-command-form' +import { createCodexHookTrustEntry } from './codex-hook-identity' +import type { CodexTrustEntry } from './config-toml-trust' + +/** + * - 'add-missing-only' (every launch): adds Orca's entry to an event that has + * none, and leaves every Orca entry it finds as it is. + * - 'convert-older-forms' (app start): also rewrites an older Orca form to the + * frozen command once, in its own slot, and drops duplicates whose removal + * moves no user hook. + */ +export type RealHomeCodexHookWritePolicy = 'add-missing-only' | 'convert-older-forms' + +export type RealHomeCodexHookSlotWrite = { + eventName: string + /** Where this call's handler landed, so a withdrawal acts on that copy only. */ + groupIndex: number + handlerIndex: number + /** The handler this call replaced in its slot, or null when it appended a group. */ + replaced: HookCommandConfig | null +} + +export type RealHomeCodexHookEntryPlan = { + hooks: Record + changed: boolean + writes: RealHomeCodexHookSlotWrite[] + /** The frozen entries whose trust this build needs. */ + managedEntries: CodexTrustEntry[] +} + +type OrcaHandler = { + groupIndex: number + handlerIndex: number + hook: HookCommandConfig + form: number +} + +const DIRECT_COMMAND_KEYS = ['command', 'bash', 'powershell'] as const + +function findOrcaHandlers( + definitions: HookDefinition[], + isOrcaCommand: (command: string | undefined) => boolean, + command: string +): OrcaHandler[] { + return definitions.flatMap((definition, groupIndex) => + Array.isArray(definition.hooks) + ? definition.hooks.flatMap((hook, handlerIndex) => + isOrcaCommand(hook.command) + ? [ + { + groupIndex, + handlerIndex, + hook, + form: readCodexHookCommandForm(hook.command, command) + } + ] + : [] + ) + : [] + ) +} + +type OrcaUnit = { groupIndex: number; handlerIndex: number } | { groupIndex: number; key: string } + +function isConvertibleSlot(definition: HookDefinition): boolean { + return ( + definition.matcher === undefined && + !DIRECT_COMMAND_KEYS.some((key) => typeof definition[key] === 'string') + ) +} + +function hasCommand(definition: HookDefinition): boolean { + return ( + DIRECT_COMMAND_KEYS.some((key) => typeof definition[key] === 'string') || + (Array.isArray(definition.hooks) && definition.hooks.length > 0) + ) +} + +function withoutOrcaUnit(definitions: HookDefinition[], unit: OrcaUnit): HookDefinition[] { + const definition: HookDefinition = { ...definitions[unit.groupIndex]! } + if ('key' in unit) { + delete definition[unit.key] + } else { + definition.hooks = definition.hooks!.filter((_, index) => index !== unit.handlerIndex) + if (definition.hooks.length === 0) { + delete definition.hooks + } + } + const next = [...definitions] + if (hasCommand(definition)) { + next[unit.groupIndex] = definition + } else { + next.splice(unit.groupIndex, 1) + } + return next +} + +function userHandlerPositions( + definitions: HookDefinition[], + isOrcaCommand: (command: string | undefined) => boolean +): Map { + const positions = new Map() + definitions.forEach((definition, groupIndex) => + definition.hooks?.forEach((hook, handlerIndex) => { + if (!isOrcaCommand(hook.command)) { + positions.set(hook, `${groupIndex}:${handlerIndex}`) + } + }) + ) + return positions +} + +function movesUserHandler( + before: HookDefinition[], + after: HookDefinition[], + isOrcaCommand: (command: string | undefined) => boolean +): boolean { + const afterPositions = userHandlerPositions(after, isOrcaCommand) + return [...userHandlerPositions(before, isOrcaCommand)].some( + ([hook, position]) => afterPositions.get(hook) !== position + ) +} + +function locateHandler( + definitions: HookDefinition[], + hook: HookCommandConfig +): { groupIndex: number; handlerIndex: number } { + for (const [groupIndex, definition] of definitions.entries()) { + const handlerIndex = definition.hooks?.indexOf(hook) ?? -1 + if (handlerIndex !== -1) { + return { groupIndex, handlerIndex } + } + } + throw new Error('written Codex hook handler is missing from its plan') +} + +export function planRealHomeCodexHookEntries(args: { + hooks: Record + sourcePath: string + material: CodexManagedHookInstallMaterial + isOrcaCommand: (command: string | undefined) => boolean + policy: RealHomeCodexHookWritePolicy +}): RealHomeCodexHookEntryPlan { + const { material, isOrcaCommand, sourcePath } = args + const command = material.command + // Why: events this build does not subscribe to keep their Orca entries; a + // newer build may subscribe to them. + const hooks: Record = { ...args.hooks } + const writes: RealHomeCodexHookSlotWrite[] = [] + const managedEntries: CodexTrustEntry[] = [] + let changed = false + // Why every copy: a duplicate kept in place must not be listed for review. + const trustFrozenEntries = (eventName: string): void => { + hooks[eventName]!.forEach((definition, groupIndex) => + definition.hooks?.forEach((hook, handlerIndex) => { + const entry = + hook.command === command + ? createCodexHookTrustEntry( + sourcePath, + eventName, + groupIndex, + handlerIndex, + definition, + hook + ) + : null + if (entry) { + managedEntries.push(entry) + } + }) + ) + } + + for (const eventName of material.events) { + const current = Array.isArray(hooks[eventName]) ? hooks[eventName] : [] + const handlers = findOrcaHandlers(current, isOrcaCommand, command) + const directOrcaUnits: OrcaUnit[] = current.flatMap((definition, groupIndex) => + DIRECT_COMMAND_KEYS.filter((key) => isOrcaCommand(definition[key])).map((key) => ({ + groupIndex, + key + })) + ) + if (handlers.some((handler) => handler.form > CODEX_HOOK_COMMAND_FORM)) { + // Why: a newer build owns this event's entry; adding ours would run the hook twice. + continue + } + let definitions = current + let written: { hook: HookCommandConfig; replaced: HookCommandConfig | null } | null = null + // Why: an older build's entry still runs the shared script; converting it + // is app start's job, so launches never fight a running older build. + if (args.policy === 'convert-older-forms') { + const keeper = handlers.find((handler) => isConvertibleSlot(current[handler.groupIndex]!)) + if (keeper && keeper.hook.command !== command) { + // Why in place: the slot keeps its position, so no user trust key moves. + const slot = current[keeper.groupIndex]! + const slotHooks = [...slot.hooks!] + const hook = buildManagedCommandHook(command) + slotHooks[keeper.handlerIndex] = hook + definitions = [...current] + definitions[keeper.groupIndex] = { ...slot, hooks: slotHooks } + written = { hook, replaced: keeper.hook } + } + const others: OrcaUnit[] = [ + ...handlers.filter((handler) => handler !== keeper), + ...directOrcaUnits + ] + others.sort((a, b) => + a.groupIndex !== b.groupIndex + ? b.groupIndex - a.groupIndex + : ('handlerIndex' in b ? b.handlerIndex : -1) - + ('handlerIndex' in a ? a.handlerIndex : -1) + ) + for (const unit of others) { + const next = withoutOrcaUnit(definitions, unit) + // Why: a duplicate before a user hook stays; removing it would move that hook's trust key. + if (!movesUserHandler(definitions, next, isOrcaCommand)) { + definitions = next + } + } + } + const hasFrozen = definitions.some((definition) => + definition.hooks?.some((hook) => hook.command === command) + ) + if ( + !hasFrozen && + (args.policy === 'convert-older-forms' || + (handlers.length === 0 && directOrcaUnits.length === 0)) + ) { + // Why last: no user hook's positional trust key moves. + const hook = buildManagedCommandHook(command) + definitions = [...definitions, { hooks: [hook] }] + written = { hook, replaced: null } + } + if (written) { + // Why after the duplicate drops: they can shift the written slot. + writes.push({ + eventName, + ...locateHandler(definitions, written.hook), + replaced: written.replaced + }) + } + if (definitions !== current) { + hooks[eventName] = definitions + changed = true + } + if (hooks[eventName]) { + trustFrozenEntries(eventName) + } + } + + return { hooks, changed, writes, managedEntries } +} diff --git a/src/main/codex/codex-real-home-hook-install.test.ts b/src/main/codex/codex-real-home-hook-install.test.ts index ed0a184163c1..6de3d3891348 100644 --- a/src/main/codex/codex-real-home-hook-install.test.ts +++ b/src/main/codex/codex-real-home-hook-install.test.ts @@ -18,6 +18,7 @@ import type { CodexManagedTrustGrantPlan } from './codex-hook-trust-grant' import { computeTrustKey, readHookTrustEntries, + upsertHookTrustEntries, upsertHookTrustEntriesInContent, type CodexTrustEntry } from './config-toml-trust' @@ -34,16 +35,29 @@ vi.mock('node:os', async () => { vi.mock('./codex-hook-trust-grant', () => ({ CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS: 300_000, + findCurrentManagedCodexHookTrust: async () => null, grantManagedCodexHookTrust: grantMock })) import { - ensureRealHomeCodexHookState, - getRealHomeCodexHookLane, + ensureRealHomeCodexHookState as startRealHomeCodexHookEnsure, + getRealHomeCodexHookVerdict, + removeRealHomeCodexHookForOptOut, _internals } from './codex-real-home-hook-install' + +/** The lane once Codex's background approval, if any, has settled. */ +async function ensureRealHomeCodexHookState( + args: Parameters[0] +): ReturnType { + await startRealHomeCodexHookEnsure(args) + return _internals.settledVerdictForTesting() +} import { getCodexManagedHookInstallMaterial } from './hook-service' -import { _internals as rebaseInternals } from './codex-user-hook-trust-rebase' +import { + readCodexTrustGrantLedgerHome, + writeCodexTrustGrantLedgerHome +} from './codex-trust-grant-ledger' let fakeHomeDir: string let userDataDir: string @@ -83,12 +97,10 @@ beforeEach(() => { process.env.ORCA_USER_DATA_PATH = userDataDir homedirMock.mockReturnValue(fakeHomeDir) mkdirSync(join(fakeHomeDir, '.codex'), { recursive: true }) - _internals.setLaneForTesting('pending') + _internals.resetForTesting('pending') }) afterEach(() => { - rebaseInternals.setSessionRunner(null) - rebaseInternals.resetRetryState() rmSync(fakeHomeDir, { recursive: true, force: true }) rmSync(userDataDir, { recursive: true, force: true }) if (previousUserDataPath === undefined) { @@ -100,9 +112,8 @@ afterEach(() => { }) describe('ensureRealHomeCodexHookState (install)', () => { - // Why (#16441): the ensure chain is process-wide; a rejection that escapes it - // would return the same rejected promise to every later pane launch, with no - // retry and no cooldown recovery. + // Why (#16441): every pane launch queues on one process-wide lane, so a failure + // must resolve to a verdict and leave later ensures their retry. it('recovers from a home-resolution failure instead of poisoning later ensures', async () => { grantSucceeds() homedirMock.mockImplementationOnce(() => { @@ -110,10 +121,18 @@ describe('ensureRealHomeCodexHookState (install)', () => { }) await expect( - ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: userDataDir, + writePolicy: 'add-missing-only' + }) ).resolves.toBe('unavailable') await expect( - ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir }) + ensureRealHomeCodexHookState({ + hooksEnabled: false, + userDataPath: userDataDir, + writePolicy: 'add-missing-only' + }) ).resolves.toBe('removed') }) @@ -122,7 +141,8 @@ describe('ensureRealHomeCodexHookState (install)', () => { const lane = await ensureRealHomeCodexHookState({ hooksEnabled: true, - userDataPath: userDataDir + userDataPath: userDataDir, + writePolicy: 'add-missing-only' }) expect(lane).toBe('installed') @@ -150,7 +170,11 @@ describe('ensureRealHomeCodexHookState (install)', () => { symlinkSync(targetHome, logicalHome, process.platform === 'win32' ? 'junction' : 'dir') expect( - await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: userDataDir, + writePolicy: 'add-missing-only' + }) ).toBe('installed') const plan = grantMock.mock.calls[0]![0] as CodexManagedTrustGrantPlan @@ -173,7 +197,8 @@ describe('ensureRealHomeCodexHookState (install)', () => { const lane = await ensureRealHomeCodexHookState({ hooksEnabled: true, - userDataPath: userDataDir + userDataPath: userDataDir, + writePolicy: 'add-missing-only' }) expect(lane).toBe('unavailable') @@ -196,7 +221,11 @@ describe('ensureRealHomeCodexHookState (install)', () => { writeFileSync(getRealHooksJsonPath(), original, 'utf-8') expect( - await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: userDataDir, + writePolicy: 'add-missing-only' + }) ).toBe('installed') const config = readRealHooksJson() @@ -226,7 +255,11 @@ describe('ensureRealHomeCodexHookState (install)', () => { symlinkSync(targetPath, getRealHooksJsonPath()) expect( - await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: userDataDir, + writePolicy: 'add-missing-only' + }) ).toBe('installed') expect(lstatSync(getRealHooksJsonPath()).isSymbolicLink()).toBe(true) @@ -241,7 +274,11 @@ describe('ensureRealHomeCodexHookState (install)', () => { writeFileSync(join(userDataDir, 'codex-real-home-hooks'), 'blocks backup directory', 'utf-8') expect( - await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: userDataDir, + writePolicy: 'add-missing-only' + }) ).toBe('unavailable') expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe(original) @@ -256,7 +293,11 @@ describe('ensureRealHomeCodexHookState (install)', () => { chmodSync(getRealHooksJsonPath(), 0o600) expect( - await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: userDataDir, + writePolicy: 'add-missing-only' + }) ).toBe('installed') expect(statSync(getRealHooksJsonPath()).mode & 0o777).toBe(0o600) @@ -264,78 +305,103 @@ describe('ensureRealHomeCodexHookState (install)', () => { ) it.skipIf(process.platform === 'win32')( - 'restores restrictive hooks.json permissions after grant fallback', + 'keeps restrictive hooks.json permissions after withdrawing on grant fallback', async () => { grantUnavailable() writeFileSync(getRealHooksJsonPath(), '{ "hooks": {} }\n', 'utf-8') chmodSync(getRealHooksJsonPath(), 0o600) expect( - await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: userDataDir, + writePolicy: 'add-missing-only' + }) ).toBe('unavailable') expect(statSync(getRealHooksJsonPath()).mode & 0o777).toBe(0o600) } ) - it('rolls the file back byte-exactly when the grant lane is unavailable', async () => { + it('withdraws its untrusted entries to the prior bytes when the grant lane is unavailable', async () => { grantUnavailable() const userRaw = `${JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'mine.sh' }] }] } }, null, 2)}\n` writeFileSync(getRealHooksJsonPath(), userRaw, 'utf-8') const lane = await ensureRealHomeCodexHookState({ hooksEnabled: true, - userDataPath: userDataDir + userDataPath: userDataDir, + writePolicy: 'add-missing-only' }) expect(lane).toBe('unavailable') - expect(getRealHomeCodexHookLane()).toBe('unavailable') + expect(getRealHomeCodexHookVerdict()).toBe('unavailable') expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe(userRaw) }) - it('removes a freshly created hooks.json when the grant lane is unavailable', async () => { + it('withdraws its entries from a hooks.json it created when the grant lane is unavailable', async () => { grantUnavailable() const lane = await ensureRealHomeCodexHookState({ hooksEnabled: true, - userDataPath: userDataDir + userDataPath: userDataDir, + writePolicy: 'add-missing-only' }) expect(lane).toBe('unavailable') - expect(existsSync(getRealHooksJsonPath())).toBe(false) + expect(readRealHooksJson()).toEqual({ hooks: {} }) }) - it('surfaces rollback failures to the retry boundary', async () => { - const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) - grantMock.mockImplementation(() => { - rmSync(getRealHooksJsonPath()) - mkdirSync(getRealHooksJsonPath()) - return { lane: 'fallback', reason: 'unsupported' } - }) - - expect( - await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) - ).toBe('unavailable') + it.skipIf(process.platform === 'win32')( + 'surfaces a withdrawal that cannot write to the retry boundary', + async () => { + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const codexDir = join(fakeHomeDir, '.codex') + grantMock.mockImplementation(() => { + chmodSync(codexDir, 0o500) + return { lane: 'fallback', reason: 'unsupported' } + }) + + try { + expect( + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: userDataDir, + writePolicy: 'add-missing-only' + }) + ).toBe('unavailable') + } finally { + chmodSync(codexDir, 0o700) + } - expect(warning).toHaveBeenCalledWith( - '[codex-real-home-hooks] ensure failed; staying on managed lane:', - expect.any(Error) - ) - }) + expect(warning).toHaveBeenCalledWith( + '[codex-real-home-hooks] background trust grant failed:', + expect.any(Error) + ) + } + ) it('does no hook-file or grant work on repeated unsupported launches', async () => { grantUnavailable() expect( - await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: userDataDir, + writePolicy: 'add-missing-only' + }) ).toBe('unavailable') - expect(existsSync(getRealHooksJsonPath())).toBe(false) + const afterFirst = readFileSync(getRealHooksJsonPath(), 'utf-8') expect( - await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: userDataDir, + writePolicy: 'add-missing-only' + }) ).toBe('unavailable') expect(grantMock).toHaveBeenCalledTimes(1) - expect(existsSync(getRealHooksJsonPath())).toBe(false) + expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe(afterFirst) }) it('leaves an unparseable hooks.json untouched and keeps the managed lane', async () => { @@ -343,7 +409,8 @@ describe('ensureRealHomeCodexHookState (install)', () => { const lane = await ensureRealHomeCodexHookState({ hooksEnabled: true, - userDataPath: userDataDir + userDataPath: userDataDir, + writePolicy: 'add-missing-only' }) expect(lane).toBe('unavailable') @@ -353,12 +420,17 @@ describe('ensureRealHomeCodexHookState (install)', () => { it('is idempotent: a second ensure keeps a single appended entry per event', async () => { grantSucceeds() - await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: userDataDir, + writePolicy: 'add-missing-only' + }) const firstRaw = readFileSync(getRealHooksJsonPath(), 'utf-8') const lane = await ensureRealHomeCodexHookState({ hooksEnabled: true, - userDataPath: userDataDir + userDataPath: userDataDir, + writePolicy: 'add-missing-only' }) expect(lane).toBe('installed') @@ -373,14 +445,22 @@ describe('ensureRealHomeCodexHookState (install)', () => { `${JSON.stringify({ hooks: { Stop: [userBefore] } }, null, 2)}\n`, 'utf-8' ) - await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: userDataDir, + writePolicy: 'add-missing-only' + }) const installed = readRealHooksJson() const userAfter = { hooks: [{ type: 'command', command: 'after.sh' }] } installed.hooks!.Stop!.push(userAfter) writeFileSync(getRealHooksJsonPath(), `${JSON.stringify(installed, null, 2)}\n`, 'utf-8') expect( - await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: userDataDir, + writePolicy: 'add-missing-only' + }) ).toBe('installed') const reconciled = readRealHooksJson().hooks?.Stop @@ -392,14 +472,22 @@ describe('ensureRealHomeCodexHookState (install)', () => { it("keeps later user handler trust positions stable inside Orca's hook group", async () => { grantSucceeds() - await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: userDataDir, + writePolicy: 'add-missing-only' + }) const installed = readRealHooksJson() const userAfter = { type: 'command', command: 'after.sh' } installed.hooks!.Stop![0]!.hooks!.push(userAfter) writeFileSync(getRealHooksJsonPath(), `${JSON.stringify(installed, null, 2)}\n`, 'utf-8') expect( - await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: userDataDir, + writePolicy: 'add-missing-only' + }) ).toBe('installed') expect(readRealHooksJson().hooks?.Stop?.[0]?.hooks?.[1]).toEqual(userAfter) @@ -409,100 +497,73 @@ describe('ensureRealHomeCodexHookState (install)', () => { }) }) -describe('ensureRealHomeCodexHookState (opt-out sweep)', () => { +describe('removeRealHomeCodexHookForOptOut', () => { it('keeps the managed lane when hooks.json cannot be read', async () => { mkdirSync(getRealHooksJsonPath()) - expect( - await ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir }) - ).toBe('unavailable') + expect(await removeRealHomeCodexHookForOptOut()).toBe('unavailable') + }) + + it("keeps Orca's trust and its ledger when hooks.json cannot be read", async () => { + const entry: CodexTrustEntry = { + sourcePath: getRealHooksJsonPath(), + eventLabel: 'stop', + groupIndex: 0, + handlerIndex: 0, + command: getCodexManagedHookInstallMaterial().command, + timeoutSec: 10 + } + writeFileSync(getRealConfigTomlPath(), upsertHookTrustEntriesInContent('', [entry]), 'utf-8') + writeCodexTrustGrantLedgerHome(join(fakeHomeDir, '.codex'), { binary: null, entries: {} }) + mkdirSync(getRealHooksJsonPath()) + + expect(await removeRealHomeCodexHookForOptOut()).toBe('unavailable') + + // The entry may still be there, so its trust must be too. + expect(readHookTrustEntries(getRealConfigTomlPath()).has(computeTrustKey(entry))).toBe(true) + expect(readCodexTrustGrantLedgerHome(join(fakeHomeDir, '.codex'))).not.toBeNull() }) it('keeps the managed lane when hooks.json is malformed', async () => { writeFileSync(getRealHooksJsonPath(), '{ not json', 'utf-8') - expect( - await ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir }) - ).toBe('unavailable') + expect(await removeRealHomeCodexHookForOptOut()).toBe('unavailable') expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe('{ not json') }) - it('rebases trust when a user appended hooks after Orca installed', async () => { + it('moves the trust of a user hook appended after Orca when the opt-out removes Orca', async () => { grantSucceeds() const before = { type: 'command', command: 'before.sh' } writeFileSync( getRealHooksJsonPath(), `${JSON.stringify({ hooks: { Stop: [{ hooks: [before] }] } }, null, 2)}\n` ) - await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) - const installed = readRealHooksJson() - const after = { type: 'command', command: 'after.sh' } - installed.hooks!.Stop!.push({ hooks: [after] }) - writeFileSync(getRealHooksJsonPath(), `${JSON.stringify(installed, null, 2)}\n`) - const operations: string[] = [] - rebaseInternals.setSessionRunner(async (request) => { - operations.push(request.operation) - if (request.operation === 'inspect-user-hook-trust') { - expect(readRealHooksJson().hooks?.Stop?.[2]?.hooks?.[0]?.command).toBe('after.sh') - return { - outcome: 'inspected', - moves: request.moves.map((move) => ({ - ...move, - reportedOldKey: move.oldKey, - wasTrusted: true, - enabled: true - })) - } - } - expect(readRealHooksJson().hooks?.Stop?.[1]?.hooks?.[0]?.command).toBe('after.sh') - return { outcome: 'repaired', repaired: 1 } + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: userDataDir, + writePolicy: 'add-missing-only' }) - - expect( - await ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir }) - ).toBe('removed') - expect(operations).toEqual(['inspect-user-hook-trust', 'repair-user-hook-trust']) - expect(readRealHooksJson().hooks?.Stop).toEqual([{ hooks: [before] }, { hooks: [after] }]) - }) - - it('aborts without writing when hooks.json changes during the trust inspection', async () => { - grantSucceeds() - const before = { type: 'command', command: 'before.sh' } - writeFileSync( - getRealHooksJsonPath(), - `${JSON.stringify({ hooks: { Stop: [{ hooks: [before] }] } }, null, 2)}\n` - ) - await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) const installed = readRealHooksJson() const after = { type: 'command', command: 'after.sh' } installed.hooks!.Stop!.push({ hooks: [after] }) writeFileSync(getRealHooksJsonPath(), `${JSON.stringify(installed, null, 2)}\n`) - const userTrustToml = '[hooks.state."x:stop:0:0"]\ntrusted_hash = "user"\n' - writeFileSync(getRealConfigTomlPath(), userTrustToml, 'utf-8') - const concurrentSave = `${JSON.stringify({ hooks: { Stop: [{ hooks: [before] }] } }, null, 2)}\n` - const operations: string[] = [] - rebaseInternals.setSessionRunner(async (request) => { - operations.push(request.operation) - // A user save (or a second Orca instance) lands while the RPC runs. - writeFileSync(getRealHooksJsonPath(), concurrentSave, 'utf-8') - return { - outcome: 'inspected', - moves: request.moves.map((move) => ({ - ...move, - reportedOldKey: move.oldKey, - wasTrusted: true, - enabled: true - })) - } + const afterAt = (groupIndex: number) => ({ + sourcePath: getRealHooksJsonPath(), + eventLabel: 'stop' as const, + groupIndex, + handlerIndex: 0, + command: 'after.sh' }) + upsertHookTrustEntries(getRealConfigTomlPath(), [ + { ...afterAt(2), trustedHash: 'sha256:user-approved' } + ]) - expect( - await ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir }) - ).toBe('unavailable') + expect(await removeRealHomeCodexHookForOptOut()).toBe('removed') - expect(operations).toEqual(['inspect-user-hook-trust']) - expect(readFileSync(getRealHooksJsonPath(), 'utf-8')).toBe(concurrentSave) - expect(readFileSync(getRealConfigTomlPath(), 'utf-8')).toBe(userTrustToml) + expect(readRealHooksJson().hooks?.Stop).toEqual([{ hooks: [before] }, { hooks: [after] }]) + const trust = readHookTrustEntries(getRealConfigTomlPath()) + expect(trust.get(computeTrustKey(afterAt(1)))?.trustedHash).toBe('sha256:user-approved') + expect(trust.get(computeTrustKey(afterAt(2)))).toBeUndefined() }) it('removes only Orca entries and reports the removed lane', async () => { @@ -516,13 +577,14 @@ describe('ensureRealHomeCodexHookState (opt-out sweep)', () => { `${JSON.stringify({ hooks: { Stop: [userStop] } }, null, 2)}\n`, 'utf-8' ) - await ensureRealHomeCodexHookState({ hooksEnabled: true, userDataPath: userDataDir }) + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: userDataDir, + writePolicy: 'add-missing-only' + }) expect(readRealHooksJson().hooks?.Stop).toHaveLength(2) - const lane = await ensureRealHomeCodexHookState({ - hooksEnabled: false, - userDataPath: userDataDir - }) + const lane = await removeRealHomeCodexHookForOptOut() expect(lane).toBe('removed') const config = readRealHooksJson() @@ -537,10 +599,7 @@ describe('ensureRealHomeCodexHookState (opt-out sweep)', () => { }) it('no-ops the sweep when the real home has no hooks.json', async () => { - const lane = await ensureRealHomeCodexHookState({ - hooksEnabled: false, - userDataPath: userDataDir - }) + const lane = await removeRealHomeCodexHookForOptOut() expect(lane).toBe('removed') expect(existsSync(getRealHooksJsonPath())).toBe(false) @@ -588,9 +647,7 @@ describe('ensureRealHomeCodexHookState (opt-out sweep)', () => { ] writeFileSync(getRealConfigTomlPath(), upsertHookTrustEntriesInContent('', entries), 'utf-8') - expect( - await ensureRealHomeCodexHookState({ hooksEnabled: false, userDataPath: userDataDir }) - ).toBe('removed') + expect(await removeRealHomeCodexHookForOptOut()).toBe('removed') expect(readRealHooksJson().hooks?.Stop).toEqual([ { hooks: [{ type: 'command', command: userCommand }] } diff --git a/src/main/codex/codex-real-home-hook-install.ts b/src/main/codex/codex-real-home-hook-install.ts index f5ab1b279c77..eea13bb8ada2 100644 --- a/src/main/codex/codex-real-home-hook-install.ts +++ b/src/main/codex/codex-real-home-hook-install.ts @@ -1,312 +1,351 @@ -import { statSync } from 'node:fs' import { createManagedCommandMatcher, - MANAGED_HOOK_TIMEOUT_SECONDS, readHooksJsonWithRaw, - removeManagedCommands, writeHooksJson, - writeManagedScript, - type HookDefinition, - type HooksConfig + writeManagedScript } from '../agent-hooks/installer-utils' import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path' import { assertHooksJsonGeneration, backupRealHomeHooksJsonOnce, getRealHomeConfigTomlPath, - getRealHomeHooksJsonPath, - reconcileManagedHookDefinition, - restoreRealHomeHooksJson + getRealHomeHooksJsonPath } from './codex-real-home-hooks-json' import { getCodexManagedScriptFileName } from './codex-hook-identity' import { CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS, - grantManagedCodexHookTrust, - type CodexTrustGrantFallbackReason + findCurrentManagedCodexHookTrust, + type CodexManagedTrustGrantOutcome, + type CodexManagedTrustGrantPlan } from './codex-hook-trust-grant' -import { removeCodexManagedHookTrustEntries } from './codex-managed-trust-reconciliation' -import { getCodexManagedHookInstallMaterial } from './hook-service' +import { readCodexTrustGrantLedgerHomeForReconciliation } from './codex-managed-trust-reconciliation' +import { removeSystemManagedHookTrustEntries } from './codex-hook-trust-cleanup' +import { getCodexManagedHookInstallMaterial } from './codex-hook-definition' import { getSystemCodexHomePath } from './codex-home-paths' -import type { CodexTrustEntry } from './config-toml-trust' -import { restoreCodexTrustConfig } from './codex-trust-config-rollback' -import { mutateRealHomeHooksPreservingUserTrust } from './codex-user-hook-trust-rebase' -import { runExclusivelyForCodexTrustConfig } from './codex-trust-config-mutation-queue' +import { mutateRealHomeHooksPreservingUserTrust } from './codex-user-hook-trust-moves' +import { sweepRealHomeCodexHook } from './codex-real-home-hook-sweep' +import { + runExclusivelyForCodexTrustConfig, + runOutsideCodexTrustConfigLanes +} from './codex-trust-config-mutation-queue' +import { + planRealHomeCodexHookEntries, + type RealHomeCodexHookSlotWrite, + type RealHomeCodexHookWritePolicy +} from './codex-real-home-hook-entry-plan' +import { + _internals as approvalInternals, + describeRealHomeApprovalRetry, + hasUnapprovedRealHomeOrcaEntry, + recordRealHomeApprovalOutcome, + requestRealHomeCodexApproval, + type RealHomeBackgroundGrant +} from './codex-real-home-background-grant' +import { withdrawUntrustedRealHomeWrites } from './codex-real-home-hook-withdrawal' + +export type { RealHomeCodexHookWritePolicy } /** - * Real-home Codex hook lane for the system-default selection (flag ON). + * What the real-home check last concluded, for the routing gate (flag ON, + * system default). Output only: no decision in this module reads it. * * - 'pending': no attempt yet this process; routing may optimistically use the * real home (reads are hook-free and the install runs before pane spawns). - * - 'installed': entry appended LAST in ~/.codex/hooks.json and trusted by - * codex itself through the app-server grant client. + * - 'approving': the entry is written, and Codex's approval runs in the + * background. Launches use the managed home until it lands; a resume waits. + * - 'installed': every managed event in ~/.codex/hooks.json has an Orca entry, + * and the frozen ones are trusted by codex itself through the app-server grant. * - 'unavailable': the grant lane could not trust the entry (old binary, - * unsupported RPC, verify failure). The entry is rolled back and the host - * stays on the managed-home lane. - * - 'removed': hooks are opted out; Orca entries are swept from the real home. + * unsupported RPC, verify failure), or its retry window is still open. An + * entry the attempt wrote that is still untrusted is withdrawn. + * - 'removed': hooks are off here. Launch prep leaves the real home as it is; + * only an explicit opt-out strips Orca's entry, since other Orcas share it. */ -export type RealHomeCodexHookLane = 'pending' | 'installed' | 'unavailable' | 'removed' +export type RealHomeCodexHookVerdict = + | 'pending' + | 'approving' + | 'installed' + | 'unavailable' + | 'removed' -let currentLane: RealHomeCodexHookLane = 'pending' +type RealHomeCodexHookIntent = { + hooksEnabled: boolean + userDataPath: string + writePolicy: RealHomeCodexHookWritePolicy +} + +type Approval = { + /** What this attempt wrote; only its own settle withdraws it. */ + writes: readonly RealHomeCodexHookSlotWrite[] + command: string + /** Settles once Codex approved, or this attempt's unapproved adds are withdrawn. */ + done: Promise +} + +let verdict: RealHomeCodexHookVerdict = 'pending' +// Why: at most one Codex approval session per process. +let approval: Approval | null = null let installRetryAfterMs = 0 -let ensureInFlight: Promise = Promise.resolve(currentLane) +let readCodexHooksEnabled: () => boolean = () => true + +export function getRealHomeCodexHookVerdict(): RealHomeCodexHookVerdict { + return verdict +} -export function getRealHomeCodexHookLane(): RealHomeCodexHookLane { - return currentLane +/** The settings' answer, read when an approval settles after its caller has gone. */ +export function setRealHomeCodexHooksEnabledReader(read: () => boolean): void { + readCodexHooksEnabled = read } /** - * Routing gate consumed by CodexRuntimeHomeService. Both a failed install and - * a failed opt-out cleanup use the managed lane so no half-mutated hook state - * can diverge from PTY, rate-limit, or commit-message routing. + * Routing gate consumed by CodexRuntimeHomeService. Never usable while an + * approval runs, whatever the verdict says. Both a failed install and a failed + * opt-out cleanup use the managed lane so no half-mutated hook state can + * diverge from PTY, rate-limit, or commit-message routing. */ export function isRealHomeCodexHookLaneUsable(): boolean { - return currentLane !== 'unavailable' + return approval === null && verdict !== 'unavailable' && verdict !== 'approving' } /** - * Ensures the real-home hook state matches the settings: installs and trusts - * the Orca status hook when enabled, sweeps it when opted out. Idempotent; - * repeat calls are cheap — an unchanged hooks.json write no-ops and a valid - * grant ledger skips the RPC session entirely. - * Never throws: any failure logs and leaves the host on the managed lane. + * Installs and trusts the Orca status hook in the real home when hooks are on, + * and writes nothing when they are off. Add-only: it never removes an Orca + * entry, and only `convert-older-forms` rewrites one. Idempotent; a home that + * already holds the frozen entry costs one read, and a valid grant ledger skips + * the RPC session. Never waits on a session: Codex's approval runs in the + * background. Never throws: any failure logs and leaves the managed lane. */ -export function ensureRealHomeCodexHookState(args: { - hooksEnabled: boolean - userDataPath: string -}): Promise { - // Why: the grant client caches failed probes, but mutating and rolling back - // hooks.json before consulting it still adds work to every pane launch. - if (args.hooksEnabled && currentLane === 'unavailable' && Date.now() < installRetryAfterMs) { - return Promise.resolve(currentLane) +export async function ensureRealHomeCodexHookState( + intent: RealHomeCodexHookIntent +): Promise { + try { + // Why one lane-held step: finding no approval running and starting one are + // atomic, and the lane also orders this write against the retired-form sweep. + return await runExclusivelyForCodexTrustConfig(getRealHomeConfigTomlPath(), () => + reconcileRealHomeCodexHook(intent) + ) + } catch (error) { + return failRealHomeCodexHookCheck(error) } - // Why: this mutates the user's real ~/.codex and the module's lane state. - // Concurrent pane launches must not interleave two of them, and the shared - // config.toml lane keeps the rebase + grant pair atomic against the managed - // installer's legacy sweep of the same file. - const run = (): Promise => runRealHomeCodexHookEnsure(args) - // Why both handlers: a rejected predecessor must not poison every later - // ensure for the process' lifetime. - ensureInFlight = ensureInFlight.then(run, run) - return ensureInFlight } -async function runRealHomeCodexHookEnsure(args: { - hooksEnabled: boolean - userDataPath: string -}): Promise { - try { - // Why inside the try: resolving the real home can throw too, and this - // function is the module's "never throws" boundary. - currentLane = await runExclusivelyForCodexTrustConfig(getRealHomeConfigTomlPath(), () => - args.hooksEnabled ? installRealHomeCodexHook(args.userDataPath) : sweepRealHomeCodexHook() - ) - if (!args.hooksEnabled || currentLane === 'installed') { +/** + * For a resume that must run in the real home, with no managed home to fall + * back to: while an approval runs and Codex would put an Orca entry up for + * review, waits for that one approval, which its session's 30 s limit bounds. + */ +export async function awaitRealHomeCodexHookTrust(): Promise { + const current = approval + if (current && hasUnapprovedRealHomeOrcaEntry(current.command)) { + await current.done + } +} + +async function reconcileRealHomeCodexHook( + intent: RealHomeCodexHookIntent +): Promise { + if (approval) { + // Why: a launch never waits on Codex's approval, and the running one covers + // add-missing. App start's conversion is the process's first check, so none waits here. + return (verdict = 'approving') + } + if (!intent.hooksEnabled) { + // Why: this runs for launch prep and startup, and the entry is shared by + // every Orca on this HOME; removing it is the explicit opt-out's job. + installRetryAfterMs = 0 + return (verdict = 'removed') + } + if (Date.now() < installRetryAfterMs) { + // Why: writing and withdrawing the entry again before then only adds work to every launch. + return (verdict = 'unavailable') + } + const install = await installRealHomeCodexHook(intent.userDataPath, intent.writePolicy) + if (!install.grant) { + if (install.verdict === 'installed') { installRetryAfterMs = 0 } - } catch (error) { - console.warn('[codex-real-home-hooks] ensure failed; staying on managed lane:', error) - currentLane = 'unavailable' - if (args.hooksEnabled) { - installRetryAfterMs = Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS + return (verdict = install.verdict) + } + approval = startApproval(install.grant) + return (verdict = 'approving') +} + +function failRealHomeCodexHookCheck(error: unknown): RealHomeCodexHookVerdict { + console.warn('[codex-real-home-hooks] ensure failed; staying on managed lane:', error) + installRetryAfterMs = Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS + return (verdict = 'unavailable') +} + +function startApproval(grant: RealHomeBackgroundGrant): Approval { + const adds = { writes: grant.writes, command: grant.command } + // Why outside the lane: the session holds none, and its settle queues for it like any writer. + const done = runOutsideCodexTrustConfigLanes(async () => { + const outcome = await requestRealHomeCodexApproval(grant.plan) + try { + await runExclusivelyForCodexTrustConfig(getRealHomeConfigTomlPath(), () => + settleApproval(adds, outcome) + ) + } catch (error) { + // Why: a settle that cannot run must still end its own flight, or every check answers 'approving'. + if (approval?.done === done) { + approval = null + } + failRealHomeCodexHookCheck(error) + } + }) + return { ...adds, done } +} + +async function settleApproval( + adds: Omit, + outcome: CodexManagedTrustGrantOutcome | null +): Promise { + const approved = outcome?.lane === 'rpc' + let withdrawn = 0 + if (!approved) { + // Why: an untrusted Orca entry surfaces as "Hooks need review". Withdraw only + // what this attempt wrote, and only while it is still untrusted: another + // Orca may have trusted the identical entry meanwhile. + try { + withdrawn = withdrawUntrustedRealHomeWrites(adds.writes, adds.command) + } catch (error) { + console.warn('[codex-real-home-hooks] background trust grant failed:', error) } } - return currentLane + installRetryAfterMs = recordRealHomeApprovalOutcome(outcome) + approval = null + // Why from the settings: hooks turned off during the session must not read as + // installed, and an opt-out that failed meanwhile may have left the entry. + if (readCodexHooksEnabled()) { + verdict = approved ? 'installed' : 'unavailable' + } else if (verdict !== 'unavailable') { + verdict = 'removed' + } + if (outcome?.lane !== 'rpc') { + console.warn( + `[codex-real-home-hooks] Codex did not approve Orca's entry (${outcome?.reason ?? 'error'}); ` + + `withdrew ${withdrawn} unapproved entr${withdrawn === 1 ? 'y' : 'ies'} this attempt added; ` + + `managed lane kept, ${describeRealHomeApprovalRetry(installRetryAfterMs)}` + ) + } } -async function installRealHomeCodexHook(userDataPath: string): Promise { +async function installRealHomeCodexHook( + userDataPath: string, + writePolicy: RealHomeCodexHookWritePolicy +): Promise<{ verdict: RealHomeCodexHookVerdict; grant?: RealHomeBackgroundGrant }> { const material = getCodexManagedHookInstallMaterial() const hooksJsonPath = getRealHomeHooksJsonPath() const hooksWritePath = resolveHooksJsonWritePath(hooksJsonPath) - // Why: the generation guard compares against these bytes before writing; a - // separate later read would let a concurrent save land between parse and - // snapshot and be silently overwritten by the stale parse. + // Why: the pre-write guard compares against these bytes; a separate later + // read would let a concurrent save land between parse and write. const { raw: previousRaw, config } = readHooksJsonWithRaw(hooksJsonPath) if (!config) { // Why: an unparseable user file must never be clobbered; without a hook // entry the managed lane keeps status working for this host. console.warn('[codex-real-home-hooks] could not parse', hooksJsonPath, '- managed lane kept') installRetryAfterMs = Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS - return 'unavailable' + return { verdict: 'unavailable' } } if (Object.keys(config).some((key) => key !== 'hooks')) { // Why: Codex rejects unknown root keys instead of ignoring them. Avoid a // transient rewrite of a user-owned file that the trust RPC cannot load. installRetryAfterMs = Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS - return 'unavailable' + return { verdict: 'unavailable' } } // Why: the same script the managed lane maintains; deploying here too keeps // host-connect ordering independent of the managed installer loop. writeManagedScript(material.scriptPath, material.script) - const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName()) - const nextHooks: Record = { ...config.hooks } - const managedEntries: CodexTrustEntry[] = [] - for (const eventName of material.events) { - const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] - const reconciled = reconcileManagedHookDefinition(current, isManagedCommand, material.command) - nextHooks[eventName] = reconciled.definitions - managedEntries.push({ + const plan = planRealHomeCodexHookEntries({ + hooks: config.hooks ?? {}, + sourcePath: hooksJsonPath, + material, + isOrcaCommand: createManagedCommandMatcher(getCodexManagedScriptFileName()), + policy: writePolicy + }) + if (plan.changed) { + backupRealHomeHooksJsonOnce(userDataPath, previousRaw) + mutateRealHomeHooksPreservingUserTrust({ sourcePath: hooksJsonPath, - eventLabel: material.eventLabel[eventName], - groupIndex: reconciled.groupIndex, - handlerIndex: reconciled.handlerIndex, - command: material.command, - timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + tomlPath: getRealHomeConfigTomlPath(), + beforeHooks: config.hooks ?? {}, + afterHooks: plan.hooks, + writeHooks: () => { + assertHooksJsonGeneration(hooksJsonPath, hooksWritePath, previousRaw) + // Why: unknown top-level fields belong to the user (other managers' + // metadata); unlike the managed-home writer, preserve them verbatim. + writeHooksJson(hooksWritePath, { ...config, hooks: plan.hooks }, { preserveMode: true }) + } }) } - // Why: sweep stale Orca entries out of events the managed lane no longer - // subscribes to, mirroring the managed installer's upgrade behavior. - for (const [eventName, definitions] of Object.entries(nextHooks)) { - if ((material.events as readonly string[]).includes(eventName) || !Array.isArray(definitions)) { - continue - } - const cleaned = removeManagedCommands(definitions, isManagedCommand) - if (cleaned.length === 0) { - delete nextHooks[eventName] - } else { - nextHooks[eventName] = cleaned - } + if (plan.managedEntries.length === 0) { + // Why: every event holds an entry of another form, which its writer keeps trusted. + return { verdict: 'installed' } } - const previousMode = previousRaw === null ? undefined : statSync(hooksWritePath).mode - backupRealHomeHooksJsonOnce(userDataPath, previousRaw) - // Why: unknown top-level fields belong to the user (other managers' - // metadata); unlike the managed-home writer, preserve them verbatim. - const trustConfigSnapshot = await mutateRealHomeHooksPreservingUserTrust({ - sourcePath: hooksJsonPath, - runtimeHomePath: getSystemCodexHomePath(), - tomlPath: getRealHomeConfigTomlPath(), - beforeHooks: config.hooks ?? {}, - afterHooks: nextHooks, - writeHooks: () => { - assertHooksJsonGeneration(hooksJsonPath, hooksWritePath, previousRaw) - writeHooksJson(hooksWritePath, { ...config, hooks: nextHooks } as HooksConfig, { - preserveMode: true - }) - }, - restoreHooks: () => restoreRealHomeHooksJson(hooksWritePath, previousRaw, previousMode) - }) - - const grant = await grantManagedCodexHookTrust({ + const grantPlan: CodexManagedTrustGrantPlan = { runtimeHomePath: getSystemCodexHomePath(), tomlPath: getRealHomeConfigTomlPath(), managedCommand: material.command, - managedEntries, + managedEntries: plan.managedEntries, host: { kind: 'native' }, telemetryLane: 'real-home', - useDefaultCodexHome: true - }) - if (grant.lane === 'rpc') { - return 'installed' + useDefaultCodexHome: true, + background: true } - - // Why: never leave an untrusted Orca entry in the user's real home — it - // would surface as "Hooks need review". Roll the file back to its prior - // bytes and keep this host on the managed-home lane; the grant client - // already logged the fallback reason. - try { - restoreRealHomeHooksJson(hooksWritePath, previousRaw, previousMode) - } finally { - // Why: a user-trust rebase may have succeeded before the managed grant - // failed. Roll both files back to the same pre-mutation generation. - if (trustConfigSnapshot) { - restoreCodexTrustConfig(getRealHomeConfigTomlPath(), trustConfigSnapshot) - } + if (await findCurrentManagedCodexHookTrust(grantPlan)) { + return { verdict: 'installed' } + } + return { + verdict: 'approving', + grant: { plan: grantPlan, writes: plan.writes, command: material.command } } - installRetryAfterMs = getInstallRetryAfterMs(grant.reason) - console.warn( - `[codex-real-home-hooks] trust grant unavailable (${grant.reason}); entry rolled back, managed lane kept` - ) - return 'unavailable' -} - -function getInstallRetryAfterMs(reason: CodexTrustGrantFallbackReason): number { - return reason === 'unsupported' || reason === 'unsupported-cached' || reason === 'disabled' - ? Number.POSITIVE_INFINITY - : Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS } -async function sweepRealHomeCodexHook(): Promise { - const hooksJsonPath = getRealHomeHooksJsonPath() - // Why: single read — the pre-write generation guard must compare against - // the exact bytes this sweep's parse came from. - const { raw: previousRaw, config } = readHooksJsonWithRaw(hooksJsonPath) - if (!config) { - // Why: a failed or malformed read proves no cleanup; keep the managed lane - // until a later pass can inspect and remove the real-home entry. - return 'unavailable' - } - if (!config.hooks || previousRaw === null) { - return 'removed' - } - const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName()) - const material = getCodexManagedHookInstallMaterial() - const nextHooks: Record = { ...config.hooks } - let removedAny = false - for (const [eventName, definitions] of Object.entries(nextHooks)) { - if (!Array.isArray(definitions)) { - continue - } - const cleaned = removeManagedCommands(definitions, isManagedCommand) - if ( - cleaned.length !== definitions.length || - cleaned.some((definition, index) => definition !== definitions[index]) - ) { - removedAny = true - } - if (cleaned.length === 0) { - delete nextHooks[eventName] - } else { - nextHooks[eventName] = cleaned - } - } - if (removedAny) { - const hooksWritePath = resolveHooksJsonWritePath(hooksJsonPath) - const previousMode = statSync(hooksWritePath).mode - await mutateRealHomeHooksPreservingUserTrust({ - sourcePath: hooksJsonPath, - runtimeHomePath: getSystemCodexHomePath(), - tomlPath: getRealHomeConfigTomlPath(), - beforeHooks: config.hooks, - afterHooks: nextHooks, - writeHooks: () => { - assertHooksJsonGeneration(hooksJsonPath, hooksWritePath, previousRaw) - writeHooksJson( - hooksWritePath, - { - ...config, - hooks: nextHooks - } as HooksConfig, - { preserveMode: true } - ) - }, - restoreHooks: () => restoreRealHomeHooksJson(hooksWritePath, previousRaw, previousMode) +/** + * The user's explicit opt-out: strips Orca's entry and its trust from the real + * ~/.codex. Joins the system lane an opt-out caller already holds. + */ +export async function removeRealHomeCodexHookForOptOut(): Promise { + try { + const lane = await runExclusivelyForCodexTrustConfig(getRealHomeConfigTomlPath(), async () => { + const lane = await sweepRealHomeCodexHook() + const systemHomePath = getSystemCodexHomePath() + // Why 'removed' only: an unread or malformed file may still hold the entry, + // so its trust and the ledger that proves ownership must wait for a later pass. + if ( + lane === 'removed' && + readCodexTrustGrantLedgerHomeForReconciliation(systemHomePath) !== null + ) { + // Why: the ledger outlives a sweep that removed the entry but not its trust. + removeSystemManagedHookTrustEntries(systemHomePath, getRealHomeHooksJsonPath()) + } + return lane }) - // Why: dead [hooks.state] blocks for a removed hook are Orca-owned records; - // dropping them keeps the user's config.toml from accumulating orphans. - // Verify ownership by the expected hash or grant ledger: stale/mixed hook - // groups must never make Orca delete a user's trust record at the same key. - try { - removeCodexManagedHookTrustEntries({ - tomlPath: getRealHomeConfigTomlPath(), - runtimeHomePath: getSystemCodexHomePath(), - sourcePath: hooksJsonPath, - command: material.command, - managedEventLabels: new Set(Object.values(material.eventLabel)), - timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS - }) - } catch (error) { - console.warn('[codex-real-home-hooks] failed to drop Orca trust entries:', error) - } + verdict = lane + } catch (error) { + console.warn('[codex-real-home-hooks] opt-out cleanup failed; staying on managed lane:', error) + verdict = 'unavailable' } - return 'removed' + return verdict } export const _internals = { - setLaneForTesting(lane: RealHomeCodexHookLane): void { - currentLane = lane + resetForTesting(state: RealHomeCodexHookVerdict): void { + verdict = state + approval = null installRetryAfterMs = 0 - ensureInFlight = Promise.resolve(lane) + readCodexHooksEnabled = () => true + approvalInternals.resetTimeoutStreakForTesting() + }, + /** The verdict once every queued check and any approval it started have settled. */ + async settledVerdictForTesting(): Promise { + await runExclusivelyForCodexTrustConfig(getRealHomeConfigTomlPath(), async () => {}) + while (approval) { + await approval.done + } + return verdict } } diff --git a/src/main/codex/codex-real-home-hook-sweep.ts b/src/main/codex/codex-real-home-hook-sweep.ts new file mode 100644 index 000000000000..71560c1329e6 --- /dev/null +++ b/src/main/codex/codex-real-home-hook-sweep.ts @@ -0,0 +1,86 @@ +import { + createManagedCommandMatcher, + MANAGED_HOOK_TIMEOUT_SECONDS, + readHooksJsonWithRaw, + removeManagedCommands, + writeHooksJson, + type HookDefinition +} from '../agent-hooks/installer-utils' +import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path' +import { + assertHooksJsonGeneration, + getRealHomeConfigTomlPath, + getRealHomeHooksJsonPath +} from './codex-real-home-hooks-json' +import { getCodexManagedScriptFileName } from './codex-hook-identity' +import { removeCodexManagedHookTrustEntries } from './codex-managed-trust-reconciliation' +import { getCodexManagedHookInstallMaterial } from './codex-hook-definition' +import { getSystemCodexHomePath } from './codex-home-paths' +import { mutateRealHomeHooksPreservingUserTrust } from './codex-user-hook-trust-moves' + +/** The opt-out's removal of every Orca entry from the real ~/.codex/hooks.json, with its trust. */ +export async function sweepRealHomeCodexHook(): Promise<'removed' | 'unavailable'> { + const hooksJsonPath = getRealHomeHooksJsonPath() + // Why: single read — the pre-write generation guard must compare against + // the exact bytes this sweep's parse came from. + const { raw: previousRaw, config } = readHooksJsonWithRaw(hooksJsonPath) + if (!config) { + // Why: a failed or malformed read proves no cleanup; keep the managed lane + // until a later pass can inspect and remove the real-home entry. + return 'unavailable' + } + if (!config.hooks || previousRaw === null) { + return 'removed' + } + const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName()) + const material = getCodexManagedHookInstallMaterial() + const nextHooks: Record = { ...config.hooks } + let removedAny = false + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (!Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if ( + cleaned.length !== definitions.length || + cleaned.some((definition, index) => definition !== definitions[index]) + ) { + removedAny = true + } + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + if (removedAny) { + const hooksWritePath = resolveHooksJsonWritePath(hooksJsonPath) + mutateRealHomeHooksPreservingUserTrust({ + sourcePath: hooksJsonPath, + tomlPath: getRealHomeConfigTomlPath(), + beforeHooks: config.hooks, + afterHooks: nextHooks, + writeHooks: () => { + assertHooksJsonGeneration(hooksJsonPath, hooksWritePath, previousRaw) + writeHooksJson(hooksWritePath, { ...config, hooks: nextHooks }, { preserveMode: true }) + } + }) + // Why: dead [hooks.state] blocks for a removed hook are Orca-owned records; + // dropping them keeps the user's config.toml from accumulating orphans. + // Verify ownership by the expected hash or grant ledger: stale/mixed hook + // groups must never make Orca delete a user's trust record at the same key. + try { + removeCodexManagedHookTrustEntries({ + tomlPath: getRealHomeConfigTomlPath(), + runtimeHomePath: getSystemCodexHomePath(), + sourcePath: hooksJsonPath, + command: material.command, + managedEventLabels: new Set(Object.values(material.eventLabel)), + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + }) + } catch (error) { + console.warn('[codex-real-home-hooks] failed to drop Orca trust entries:', error) + } + } + return 'removed' +} diff --git a/src/main/codex/codex-real-home-hook-withdrawal.ts b/src/main/codex/codex-real-home-hook-withdrawal.ts new file mode 100644 index 000000000000..6b7d17a34132 --- /dev/null +++ b/src/main/codex/codex-real-home-hook-withdrawal.ts @@ -0,0 +1,105 @@ +import { + readHooksJsonWithRaw, + writeHooksJson, + type HookCommandConfig, + type HookDefinition +} from '../agent-hooks/installer-utils' +import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path' +import { createCodexHookTrustEntry } from './codex-hook-identity' +import type { RealHomeCodexHookSlotWrite } from './codex-real-home-hook-entry-plan' +import { + assertHooksJsonGeneration, + getRealHomeConfigTomlPath, + getRealHomeHooksJsonPath +} from './codex-real-home-hooks-json' +import { readHookTrustEntries } from './config-toml-trust' +import { readOrcaEntryTrust } from './codex-real-home-entry-trust' +import { mutateRealHomeHooksPreservingUserTrust } from './codex-user-hook-trust-moves' + +function withdrawHandler( + definitions: HookDefinition[], + location: { groupIndex: number; handlerIndex: number }, + replaced: HookCommandConfig | null +): HookDefinition[] { + const definition = definitions[location.groupIndex]! + const hooks = [...definition.hooks!] + if (replaced) { + hooks[location.handlerIndex] = replaced + } else { + hooks.splice(location.handlerIndex, 1) + } + const next = [...definitions] + if (hooks.length === 0) { + next.splice(location.groupIndex, 1) + } else { + next[location.groupIndex] = { ...definition, hooks } + } + return next +} + +/** + * After a failed trust grant: takes back each entry this call wrote that is still + * untrusted, putting back the handler it replaced. Re-reads both files, so a + * concurrent edit, or the identical entry another Orca trusted, survives. + */ +export function withdrawUntrustedRealHomeWrites( + writes: readonly RealHomeCodexHookSlotWrite[], + command: string +): number { + if (writes.length === 0) { + return 0 + } + const hooksJsonPath = getRealHomeHooksJsonPath() + const hooksWritePath = resolveHooksJsonWritePath(hooksJsonPath) + const { raw: previousRaw, config } = readHooksJsonWithRaw(hooksJsonPath) + if (!config?.hooks) { + return 0 + } + const trustStates = readHookTrustEntries(getRealHomeConfigTomlPath()) + const nextHooks: Record = { ...config.hooks } + let withdrew = 0 + for (const { eventName, replaced, ...location } of writes) { + const definitions = nextHooks[eventName] + const definition = Array.isArray(definitions) ? definitions[location.groupIndex] : undefined + const handler = Array.isArray(definition?.hooks) + ? definition.hooks[location.handlerIndex] + : undefined + // Why: a copy that moved is not provably this call's; the next launch's grant retries it. + if (!definitions || !definition || handler?.command !== command) { + continue + } + const entry = createCodexHookTrustEntry( + hooksJsonPath, + eventName, + location.groupIndex, + location.handlerIndex, + definition, + handler + ) + const trust = entry ? readOrcaEntryTrust(entry, trustStates) : 'untrusted' + if (trust === 'trusted' || trust === 'disabled') { + continue + } + const next = withdrawHandler(definitions, location, replaced) + if (next.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = next + } + withdrew += 1 + } + if (withdrew > 0) { + // Why: a hook appended after this entry meanwhile moves up a slot; its trust moves with it. + mutateRealHomeHooksPreservingUserTrust({ + sourcePath: hooksJsonPath, + tomlPath: getRealHomeConfigTomlPath(), + beforeHooks: config.hooks, + afterHooks: nextHooks, + writeHooks: () => { + assertHooksJsonGeneration(hooksJsonPath, hooksWritePath, previousRaw) + writeHooksJson(hooksWritePath, { ...config, hooks: nextHooks }, { preserveMode: true }) + } + }) + } + return withdrew +} diff --git a/src/main/codex/codex-real-home-hooks-json.ts b/src/main/codex/codex-real-home-hooks-json.ts index 0b9f63003bb1..3549ee2da505 100644 --- a/src/main/codex/codex-real-home-hooks-json.ts +++ b/src/main/codex/codex-real-home-hooks-json.ts @@ -1,16 +1,11 @@ -import { existsSync, mkdirSync, readFileSync, unlinkSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { writeFileAtomically } from '../codex-accounts/fs-utils' -import { - buildManagedCommandHook, - removeManagedCommands, - type HookDefinition -} from '../agent-hooks/installer-utils' import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path' import { getSystemCodexHomePath } from './codex-home-paths' -/** The user's real `~/.codex` hook files, plus the guards and rollback the - * real-home lane needs before it is allowed to mutate them. */ +/** The user's real `~/.codex` hook files, plus the guard and pristine backup + * the real-home lane needs before it is allowed to mutate them. */ export function getRealHomeHooksJsonPath(): string { return join(getSystemCodexHomePath(), 'hooks.json') } @@ -31,9 +26,9 @@ export function assertHooksJsonGeneration( ): void { const currentRaw = existsSync(hooksJsonPath) ? readFileSync(hooksJsonPath, 'utf-8') : null if (currentRaw !== expectedRaw || resolveHooksJsonWritePath(hooksJsonPath) !== hooksWritePath) { - // Why: the pre-mutation RPC can overlap a user's editor save. Abort rather - // than atomically replacing a newer file with the stale parsed snapshot. - throw new Error('Codex hooks.json changed while Orca prepared its trust repair') + // Why: another process may have saved since the read. Abort rather than + // atomically replacing a newer file with the stale parsed snapshot. + throw new Error('Codex hooks.json changed since Orca read it') } } @@ -55,61 +50,3 @@ export function backupRealHomeHooksJsonOnce( mkdirSync(backupDir, { recursive: true }) writeFileAtomically(backupPath, previousRaw, { mode: 0o600 }) } - -export function restoreRealHomeHooksJson( - hooksJsonPath: string, - previousRaw: string | null, - previousMode?: number -): void { - if (previousRaw === null) { - if (existsSync(hooksJsonPath)) { - unlinkSync(hooksJsonPath) - } - return - } - // Why: rollback is part of the safety boundary. Use the shared atomic - // writer so Windows file-lock retries and failed-temp cleanup are covered. - writeFileAtomically(hooksJsonPath, previousRaw, { mode: previousMode }) -} - -/** Places Orca's managed hook in `definitions`, reusing its existing slot when - * one is unambiguous so no later user trust position shifts. */ -export function reconcileManagedHookDefinition( - current: HookDefinition[], - isManagedCommand: (command: string | undefined) => boolean, - command: string -): { definitions: HookDefinition[]; groupIndex: number; handlerIndex: number } { - const directCommandKeys = ['command', 'bash', 'powershell'] as const - const hasManagedDirectCommand = current.some((definition) => - directCommandKeys.some((key) => isManagedCommand(definition[key])) - ) - const nestedLocations = current.flatMap((definition, groupIndex) => - Array.isArray(definition.hooks) - ? definition.hooks.flatMap((hook, handlerIndex) => - isManagedCommand(hook.command) ? [{ groupIndex, handlerIndex }] : [] - ) - : [] - ) - if (!hasManagedDirectCommand && nestedLocations.length === 1) { - const { groupIndex, handlerIndex } = nestedLocations[0]! - const definition = current[groupIndex]! - const hasDirectCommand = directCommandKeys.some((key) => typeof definition[key] === 'string') - if (definition.matcher === undefined && !hasDirectCommand) { - const definitions = [...current] - // Why: users can append groups or handlers after Orca's first install. - // Reusing the exact slot preserves all later positional trust keys. - const hooks = [...definition.hooks!] - hooks[handlerIndex] = buildManagedCommandHook(command) - definitions[groupIndex] = { ...definition, hooks } - return { definitions, groupIndex, handlerIndex } - } - } - - const cleaned = removeManagedCommands(current, isManagedCommand) - // Why: first install appends LAST so no existing user trust position shifts. - return { - definitions: [...cleaned, { hooks: [buildManagedCommandHook(command)] }], - groupIndex: cleaned.length, - handlerIndex: 0 - } -} diff --git a/src/main/codex/codex-real-home-shared-entry.test.ts b/src/main/codex/codex-real-home-shared-entry.test.ts new file mode 100644 index 000000000000..90359bffe3ea --- /dev/null +++ b/src/main/codex/codex-real-home-shared-entry.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it, vi } from 'vitest' +import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs' +import type * as Os from 'node:os' +import { join } from 'node:path' +import { + computeTrustKey, + normalizeHookTrustKeyForLookup, + upsertHookTrustEntries, + type CodexTrustEntry +} from './config-toml-trust' +import { writeCodexTrustGrantLedgerHome } from './codex-trust-grant-ledger' +import { getCodexHookTrustSignature } from './codex-hook-identity' +import { setupCodexHookHomes } from './hook-service-test-harness' + +const { getPathMock, homedirMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>(), + homedirMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ app: { getPath: getPathMock } })) +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, homedir: homedirMock } +}) + +import { CodexHookService, getCodexManagedHookInstallMaterial } from './hook-service' +import { + _internals as realHomeInternals, + ensureRealHomeCodexHookState +} from './codex-real-home-hook-install' +import { getOrcaManagedCodexHomePath } from './codex-home-paths' +import { + resolveStartupManagedHookAction, + shouldInstallStartupManagedAgentHook +} from '../agent-hooks/managed-agent-hook-controls' + +// Why this file: every Orca on one HOME shares the current entry in ~/.codex. +// Opening any pane once stripped it (4139 -> 18 bytes) and its trust; only the +// user's explicit opt-out may remove it now. + +const homes = setupCodexHookHomes(homedirMock, getPathMock) +const USER_HOOK = { type: 'command', command: 'user-stop-hook.sh' } + +function systemCodexHome(): string { + return join(homes.tmpHome, '.codex') +} + +/** The real home as the real-home lane (or another Orca) leaves it, trust and ledger included. */ +function seedSharedEntry(): void { + const material = getCodexManagedHookInstallMaterial() + const hooksPath = join(systemCodexHome(), 'hooks.json') + mkdirSync(systemCodexHome(), { recursive: true }) + writeFileSync( + hooksPath, + `${JSON.stringify( + { + hooks: { + Stop: [ + { hooks: [USER_HOOK] }, + { hooks: [{ type: 'command', command: material.command, timeout: 10 }] } + ] + } + }, + null, + 2 + )}\n` + ) + const entry: CodexTrustEntry = { + sourcePath: hooksPath, + eventLabel: 'stop', + groupIndex: 1, + handlerIndex: 0, + command: material.command, + timeoutSec: 10, + trustedHash: 'sha256:codex-granted-stop' + } + writeFileSync(join(systemCodexHome(), 'config.toml'), 'model = "user-model"\n') + upsertHookTrustEntries(join(systemCodexHome(), 'config.toml'), [entry]) + writeCodexTrustGrantLedgerHome(systemCodexHome(), { + binary: null, + entries: { + [normalizeHookTrustKeyForLookup(computeTrustKey(entry))]: { + signature: getCodexHookTrustSignature(entry), + trustedHash: 'sha256:codex-granted-stop' + } + } + }) +} + +function snapshotRealCodexHome(): Map { + return new Map( + readdirSync(systemCodexHome()).map((name) => { + const path = join(systemCodexHome(), name) + return [name, { bytes: readFileSync(path, 'utf-8'), mtimeMs: statSync(path).mtimeMs }] + }) + ) +} + +describe('the shared real-home Codex entry', () => { + it('survives a pane spawn under a managed account, with no .bak', async () => { + seedSharedEntry() + const before = snapshotRealCodexHome() + const accountHome = join(homes.userDataDir, 'codex-accounts', 'account-1', 'home') + mkdirSync(accountHome, { recursive: true }) + + const status = await new CodexHookService().prepareRuntimeHomeForLaunch( + accountHome, + undefined, + true + ) + + expect(status.state).toBe('installed') + expect(snapshotRealCodexHome()).toEqual(before) + }) + + it('survives launch prep on the real-home lane with hooks off', async () => { + seedSharedEntry() + realHomeInternals.resetForTesting('installed') + const before = snapshotRealCodexHome() + + expect( + await ensureRealHomeCodexHookState({ + hooksEnabled: false, + userDataPath: homes.userDataDir, + writePolicy: 'add-missing-only' + }) + ).toBe('removed') + + expect(snapshotRealCodexHome()).toEqual(before) + }) + + it('survives a startup with hooks off and its first pane launch prep', async () => { + seedSharedEntry() + const before = snapshotRealCodexHome() + const settings = { agentStatusHooksEnabled: false, disabledTuiAgents: [] } + + // Startup: the real-home install and the managed installs are both skipped. + expect(resolveStartupManagedHookAction(settings)).toBe('skip') + expect(shouldInstallStartupManagedAgentHook(settings, 'codex')).toBe(false) + // First pane: both lanes run with hooks off. + await ensureRealHomeCodexHookState({ + hooksEnabled: false, + userDataPath: homes.userDataDir, + writePolicy: 'add-missing-only' + }) + await new CodexHookService().prepareRuntimeHomeForLaunch( + getOrcaManagedCodexHomePath(), + undefined, + false + ) + + expect(snapshotRealCodexHome()).toEqual(before) + }) + + it("is removed, with Orca's trust, only by the user's explicit opt-out", async () => { + seedSharedEntry() + + await new CodexHookService().remove() + + const hooks = JSON.parse(readFileSync(join(systemCodexHome(), 'hooks.json'), 'utf-8')) + expect(hooks.hooks.Stop).toEqual([{ hooks: [USER_HOOK] }]) + const toml = readFileSync(join(systemCodexHome(), 'config.toml'), 'utf-8') + expect(toml).toContain('model = "user-model"') + expect(toml).not.toContain(':stop:1:0') + }) +}) diff --git a/src/main/codex/codex-real-home-slow-app-server.test.ts b/src/main/codex/codex-real-home-slow-app-server.test.ts new file mode 100644 index 000000000000..546a448a5644 --- /dev/null +++ b/src/main/codex/codex-real-home-slow-app-server.test.ts @@ -0,0 +1,620 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import type * as Os from 'node:os' +import { dirname, join } from 'node:path' +import type { HookDefinition } from '../agent-hooks/installer-utils' +import type { CodexHookTrustGrantRequest } from './codex-app-server-client' +import { CodexAppServerTimeoutError } from './codex-app-server-session' +import { + CODEX_BACKGROUND_TRUST_GRANT_TIMEOUT_MS, + _internals as grantInternals +} from './codex-hook-trust-grant' +import { + computeTrustedHash, + computeTrustKey, + normalizeHookTrustKeyForLookup, + parseTrustKey, + readHookTrustEntries, + upsertHookTrustEntries +} from './config-toml-trust' +import { isCodexManagedCommand, setupCodexHookHomes } from './hook-service-test-harness' + +const { getPathMock, homedirMock, resolveCodexCommandMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>(), + homedirMock: vi.fn<() => string>(), + resolveCodexCommandMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ app: { getPath: getPathMock } })) +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, homedir: homedirMock } +}) +vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock })) + +import { + _internals as realHomeInternals, + ensureRealHomeCodexHookState, + isRealHomeCodexHookLaneUsable, + removeRealHomeCodexHookForOptOut, + setRealHomeCodexHooksEnabledReader +} from './codex-real-home-hook-install' +import { cleanupLegacySystemManagedHooks } from './codex-hook-legacy-cleanup' +import { runExclusivelyForCodexTrustConfig } from './codex-trust-config-mutation-queue' +import { getCodexManagedHookInstallMaterial } from './codex-hook-definition' +import { createCodexHookTrustEntry } from './codex-hook-identity' +import { getOrcaManagedCodexHomePath } from './codex-home-paths' +import { readOrcaEntryTrust } from './codex-real-home-entry-trust' + +// Why this file (QA case 4): a cold `codex app-server` on a loaded Mac took over +// 10 s. A launch must never wait on that approval, the approval must still land, +// and a failed one must not block the next try for minutes. + +const homes = setupCodexHookHomes(homedirMock, getPathMock) +const USER_HOOK: HookDefinition = { hooks: [{ type: 'command', command: 'user-hook.sh' }] } + +function hooksPath(): string { + return join(homes.tmpHome, '.codex', 'hooks.json') +} + +function configPath(): string { + return join(homes.tmpHome, '.codex', 'config.toml') +} + +function readHooks(): Record { + const file: { hooks: Record } = JSON.parse( + readFileSync(hooksPath(), 'utf-8') + ) + return file.hooks +} + +function orcaHandlerCount(): number { + return Object.values(readHooks()) + .flat() + .flatMap((definition) => definition.hooks ?? []) + .filter((hook) => isCodexManagedCommand(hook.command)).length +} + +function orcaEntryTrust(): string[] { + const trust = readHookTrustEntries(configPath()) + return Object.entries(readHooks()).flatMap(([eventName, definitions]) => + definitions.flatMap((definition, groupIndex) => + (definition.hooks ?? []).flatMap((hook, handlerIndex) => { + if (!isCodexManagedCommand(hook.command)) { + return [] + } + const entry = createCodexHookTrustEntry( + hooksPath(), + eventName, + groupIndex, + handlerIndex, + definition, + hook + ) + return [entry ? readOrcaEntryTrust(entry, trust) : 'untrusted'] + }) + ) + ) +} + +/** The trust keys of the handlers in hooks.json that run `command`. */ +function keysRunning(command: string): Set { + return new Set( + Object.entries(readHooks()).flatMap(([eventName, definitions]) => + definitions.flatMap((definition, groupIndex) => + (definition.hooks ?? []).flatMap((hook, handlerIndex) => { + const entry = + hook.command === command + ? createCodexHookTrustEntry( + hooksPath(), + eventName, + groupIndex, + handlerIndex, + definition, + hook + ) + : null + return entry ? [normalizeHookTrustKeyForLookup(computeTrustKey(entry))] : [] + }) + ) + ) + ) +} + +type AppServer = { sessions: number; start: () => void } + +/** + * An app-server whose start takes `coldStartMs`, ending when `start()` is called. + * Past the session deadline it times out, as the real session does. + */ +function installAppServer( + coldStartMs: number, + failure?: Error, + grant: typeof grantInternals = grantInternals +): AppServer { + let start: () => void = () => {} + const started = new Promise((resolve) => { + start = resolve + }) + const server: AppServer = { sessions: 0, start: () => start() } + grant.setGrantSessionRunner(async (request: CodexHookTrustGrantRequest) => { + server.sessions += 1 + await started + if (coldStartMs > request.invocation.timeoutMs) { + throw new CodexAppServerTimeoutError( + `codex app-server session exceeded ${request.invocation.timeoutMs}ms` + ) + } + if (failure) { + throw failure + } + // Why: Codex lists what hooks.json holds when it answers, not what the install wrote. + const listed = keysRunning(request.managedCommand) + if ( + !request.expectedTrustKeys.every((key) => listed.has(normalizeHookTrustKeyForLookup(key))) + ) { + return { outcome: 'verify-failed', reason: 'list mismatch', reasonClass: 'list-mismatch' } + } + const entries = request.expectedTrustKeys.map((key) => { + const entry = { ...parseTrustKey(key)!, command: request.managedCommand, timeoutSec: 10 } + return { key, entry, trustedHash: computeTrustedHash(entry) } + }) + upsertHookTrustEntries( + configPath(), + entries.map(({ entry, trustedHash }) => ({ ...entry, trustedHash })) + ) + return { + outcome: 'granted' as const, + wroteTrust: true, + entries: entries.map(({ key, trustedHash }) => ({ + key, + normalizedKey: normalizeHookTrustKeyForLookup(key), + trustedHash + })) + } + }) + return server +} + +function launch(): ReturnType { + return ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: homes.userDataDir, + writePolicy: 'add-missing-only' + }) +} + +let warn: ReturnType + +beforeEach(() => { + realHomeInternals.resetForTesting('pending') + resolveCodexCommandMock.mockReturnValue(process.execPath) + mkdirSync(join(homes.tmpHome, '.codex'), { recursive: true }) + writeFileSync(hooksPath(), `${JSON.stringify({ hooks: { Stop: [USER_HOOK] } }, null, 2)}\n`) + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('a slow codex app-server start', () => { + it('never holds up a launch: a 15 s start grants in the background for the next launch', async () => { + const server = installAppServer(15_000) + + const startedAt = performance.now() + expect(await launch()).toBe('approving') + expect(await launch()).toBe('approving') + expect(performance.now() - startedAt).toBeLessThan(1_000) + // Why: a launch that finds trust not ready uses the managed home. + expect(isRealHomeCodexHookLaneUsable()).toBe(false) + expect(orcaHandlerCount()).toBe(getCodexManagedHookInstallMaterial().events.length) + + server.start() + expect(await realHomeInternals.settledVerdictForTesting()).toBe('installed') + + expect(await launch()).toBe('installed') + expect(isRealHomeCodexHookLaneUsable()).toBe(true) + expect(server.sessions).toBe(1) + }) + + it('starts no cooldown after a timeout: the next launch tries again at once', async () => { + const hung = installAppServer(10 * 60_000) + hung.start() + + expect(await launch()).toBe('approving') + expect(await realHomeInternals.settledVerdictForTesting()).toBe('unavailable') + // Why: the failed attempt takes back its own unapproved adds, so nothing asks for review. + expect(orcaHandlerCount()).toBe(0) + const events = getCodexManagedHookInstallMaterial().events.length + expect(warn).toHaveBeenCalledWith( + expect.stringMatching( + new RegExp(`withdrew ${events} unapproved entries .*retrying on the next launch$`) + ) + ) + + const recovered = installAppServer(0) + recovered.start() + expect(await launch()).toBe('approving') + expect(await realHomeInternals.settledVerdictForTesting()).toBe('installed') + expect(recovered.sessions).toBe(1) + }) + + it('backs off after three timeouts in a row, growing to 5 minutes, and a success resets it', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + let server = installAppServer(10 * 60_000) + server.start() + const timesOut = async (): Promise => { + expect(await launch()).toBe('approving') + expect(await realHomeInternals.settledVerdictForTesting()).toBe('unavailable') + } + const waitsFor = async (ms: number): Promise => { + vi.setSystemTime(Date.now() + ms - 1) + expect(await launch()).toBe('unavailable') + vi.setSystemTime(Date.now() + 1) + } + + await timesOut() + await timesOut() + await timesOut() + for (const backoffMs of [10_000, 60_000, 300_000, 300_000]) { + await waitsFor(backoffMs) + await timesOut() + } + expect(server.sessions).toBe(7) + + server = installAppServer(0) + server.start() + await waitsFor(300_000) + expect(await launch()).toBe('approving') + expect(await realHomeInternals.settledVerdictForTesting()).toBe('installed') + + // Why a ledger miss: it forces a fresh approval, which then times out again. + rmSync(join(dirname(getOrcaManagedCodexHomePath()), 'trust-grant-ledger.json')) + server = installAppServer(10 * 60_000) + server.start() + await timesOut() + await timesOut() + await timesOut() + await waitsFor(10_000) + await timesOut() + expect(server.sessions).toBe(4) + }) + + it('keeps trying after timeouts during a slow first start, once the app server answers', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + const cold = installAppServer(10 * 60_000) + cold.start() + for (let attempt = 0; attempt < 3; attempt += 1) { + expect(await launch()).toBe('approving') + expect(await realHomeInternals.settledVerdictForTesting()).toBe('unavailable') + } + + const warm = installAppServer(0) + warm.start() + vi.setSystemTime(Date.now() + 10_000) + expect(await launch()).toBe('approving') + expect(await realHomeInternals.settledVerdictForTesting()).toBe('installed') + expect(warm.sessions).toBe(1) + }) + + it('runs one session at a time, ended by its own deadline', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const spawnMs = 500 + let inFlight = 0 + let maxInFlight = 0 + grantInternals.setGrantSessionRunner(async (request: CodexHookTrustGrantRequest) => { + inFlight += 1 + maxInFlight = Math.max(maxInFlight, inFlight) + try { + // Why: the real session starts its kill timer once the app-server has spawned. + await new Promise((resolve) => setTimeout(resolve, spawnMs)) + return await new Promise((_resolve, reject) => + setTimeout( + () => reject(new CodexAppServerTimeoutError('codex app-server session timed out')), + request.invocation.timeoutMs + ) + ) + } finally { + inFlight -= 1 + } + }) + + expect(await launch()).toBe('approving') + await vi.advanceTimersByTimeAsync(CODEX_BACKGROUND_TRUST_GRANT_TIMEOUT_MS) + // Why: the session is still alive, so a launch now must not start a second one. + expect(await launch()).toBe('approving') + await vi.advanceTimersByTimeAsync(spawnMs) + expect(await realHomeInternals.settledVerdictForTesting()).toBe('unavailable') + expect(maxInFlight).toBe(1) + expect(orcaHandlerCount()).toBe(0) + }) + + it('keeps already-trusted Orca entries trusted when a later re-grant fails', async () => { + installAppServer(0).start() + expect(await launch()).toBe('approving') + expect(await realHomeInternals.settledVerdictForTesting()).toBe('installed') + const events = getCodexManagedHookInstallMaterial().events.length + expect(orcaEntryTrust()).toEqual(Array(events).fill('trusted')) + + // Why a ledger miss: another Orca profile keeps its own ledger for this shared home. + rmSync(join(dirname(getOrcaManagedCodexHomePath()), 'trust-grant-ledger.json')) + realHomeInternals.resetForTesting('pending') + const failing = installAppServer( + 0, + new Error('codex app-server exited before completing the session') + ) + failing.start() + expect(await launch()).toBe('approving') + expect(await realHomeInternals.settledVerdictForTesting()).toBe('unavailable') + + expect(failing.sessions).toBe(1) + expect(orcaEntryTrust()).toEqual(Array(events).fill('trusted')) + }) + + it('backs off for seconds, not minutes, after any other failure', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + const failing = installAppServer( + 0, + new Error('codex app-server exited before completing the session') + ) + failing.start() + + expect(await launch()).toBe('approving') + expect(await realHomeInternals.settledVerdictForTesting()).toBe('unavailable') + expect(await launch()).toBe('unavailable') + expect(failing.sessions).toBe(1) + + vi.setSystemTime(Date.now() + 10_001) + expect(await launch()).toBe('approving') + await realHomeInternals.settledVerdictForTesting() + expect(failing.sessions).toBe(2) + }) + + it('re-adds the entry after an approval that hooks off and on outlived has settled', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + let hooksOn = true + setRealHomeCodexHooksEnabledReader(() => hooksOn) + const server = installAppServer(0) + const events = getCodexManagedHookInstallMaterial().events.length + expect(await launch()).toBe('approving') + hooksOn = false + expect(await removeRealHomeCodexHookForOptOut()).toBe('removed') + expect(orcaHandlerCount()).toBe(0) + // Why: a session is still live, whatever the opt-out concluded. + expect(isRealHomeCodexHookLaneUsable()).toBe(false) + + hooksOn = true + // Why: the running session still owns this attempt; re-adding now would start a second one. + expect(await launch()).toBe('approving') + expect(orcaHandlerCount()).toBe(0) + expect(isRealHomeCodexHookLaneUsable()).toBe(false) + + server.start() + // Why: Codex lists the hooks.json the opt-out emptied, so that session cannot approve. + expect(await realHomeInternals.settledVerdictForTesting()).toBe('unavailable') + expect(server.sessions).toBe(1) + vi.setSystemTime(Date.now() + 10_001) + expect(await launch()).toBe('approving') + expect(orcaHandlerCount()).toBe(events) + expect(await realHomeInternals.settledVerdictForTesting()).toBe('installed') + expect(server.sessions).toBe(2) + expect(orcaEntryTrust()).toEqual(Array(events).fill('trusted')) + }) + + it('keeps the lane unusable while hooks read off during an approval, and settles as removed', async () => { + let hooksOn = true + setRealHomeCodexHooksEnabledReader(() => hooksOn) + const server = installAppServer(0) + expect(await launch()).toBe('approving') + + // Why: the settings flipped, and the opt-out sweep has not run yet. + hooksOn = false + expect( + await ensureRealHomeCodexHookState({ + hooksEnabled: false, + userDataPath: homes.userDataDir, + writePolicy: 'add-missing-only' + }) + ).toBe('approving') + expect(isRealHomeCodexHookLaneUsable()).toBe(false) + + server.start() + expect(await realHomeInternals.settledVerdictForTesting()).toBe('removed') + expect(server.sessions).toBe(1) + }) + + it('keeps the lane unusable when an approval settles after an opt-out that failed', async () => { + let hooksOn = true + setRealHomeCodexHooksEnabledReader(() => hooksOn) + const server = installAppServer(0) + expect(await launch()).toBe('approving') + + hooksOn = false + // Why: an opt-out that cannot parse hooks.json cannot prove the entry gone. + writeFileSync(hooksPath(), '{ "hooks": ') + expect(await removeRealHomeCodexHookForOptOut()).toBe('unavailable') + + server.start() + expect(await realHomeInternals.settledVerdictForTesting()).toBe('unavailable') + expect(isRealHomeCodexHookLaneUsable()).toBe(false) + }) + + it('settles under the config.toml lane, after a writer that holds it', async () => { + const failing = installAppServer( + 0, + new Error('codex app-server exited before completing the session') + ) + const events = getCodexManagedHookInstallMaterial().events.length + expect(await launch()).toBe('approving') + let release: () => void = () => {} + const holder = runExclusivelyForCodexTrustConfig( + configPath(), + () => new Promise((resolve) => (release = resolve)) + ) + + failing.start() + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(orcaHandlerCount()).toBe(events) + release() + await holder + expect(await realHomeInternals.settledVerdictForTesting()).toBe('unavailable') + expect(orcaHandlerCount()).toBe(0) + }) + + it("stays on the managed home when hooks come back on inside a failed approval's retry window", async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + let hooksOn = true + setRealHomeCodexHooksEnabledReader(() => hooksOn) + const failing = installAppServer( + 0, + new Error('codex app-server exited before completing the session') + ) + expect(await launch()).toBe('approving') + hooksOn = false + failing.start() + expect(await realHomeInternals.settledVerdictForTesting()).toBe('removed') + + hooksOn = true + expect(await launch()).toBe('unavailable') + expect(isRealHomeCodexHookLaneUsable()).toBe(false) + vi.setSystemTime(Date.now() + 10_001) + expect(await launch()).toBe('approving') + await realHomeInternals.settledVerdictForTesting() + }) + + // Why: app start's conversion is the first check, so none can meet an approval; + // were one to, its settle must still write nothing a resume it releases could meet. + it.skipIf(process.platform === 'win32')( + 'converts nothing during an approval, and its settle writes no new entry', + async () => { + const server = installAppServer(0) + expect(await launch()).toBe('approving') + // Why: an older build's pane launch added its own form beside Orca's entry meanwhile. + const older = join(homes.tmpHome, '.orca', 'agent-hooks', 'codex-hook.sh') + const hooks = readHooks() + hooks.Stop = [...hooks.Stop, { hooks: [{ type: 'command', command: older, timeout: 10 }] }] + writeFileSync(hooksPath(), `${JSON.stringify({ hooks }, null, 2)}\n`) + const before = readFileSync(hooksPath(), 'utf-8') + + expect( + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: homes.userDataDir, + writePolicy: 'convert-older-forms' + }) + ).toBe('approving') + + server.start() + expect(await realHomeInternals.settledVerdictForTesting()).toBe('installed') + expect(readFileSync(hooksPath(), 'utf-8')).toBe(before) + expect(server.sessions).toBe(1) + } + ) + + it('keeps the timeout streak through other failures', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + const attempt = async (server: AppServer): Promise => { + server.start() + expect(await launch()).toBe('approving') + expect(await realHomeInternals.settledVerdictForTesting()).toBe('unavailable') + } + + await attempt(installAppServer(10 * 60_000)) + await attempt(installAppServer(10 * 60_000)) + await attempt( + installAppServer(0, new Error('codex app-server exited before completing the session')) + ) + vi.setSystemTime(Date.now() + 10_001) + // Why: the third timeout in a row, counting past the failure between them. + await attempt(installAppServer(10 * 60_000)) + vi.setSystemTime(Date.now() + 9_999) + expect(await launch()).toBe('unavailable') + vi.setSystemTime(Date.now() + 1) + expect(await launch()).toBe('approving') + await realHomeInternals.settledVerdictForTesting() + }) + + it('starts the timeout streak from zero at app start', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + for (let attempt = 0; attempt < 3; attempt += 1) { + installAppServer(10 * 60_000).start() + expect(await launch()).toBe('approving') + expect(await realHomeInternals.settledVerdictForTesting()).toBe('unavailable') + } + expect(await launch()).toBe('unavailable') + + vi.resetModules() + const restarted = await import('./codex-real-home-hook-install') + const restartedGrant = await import('./codex-hook-trust-grant') + installAppServer(10 * 60_000, undefined, restartedGrant._internals).start() + const restartedLaunch = (): ReturnType => + restarted.ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: homes.userDataDir, + writePolicy: 'add-missing-only' + }) + expect(await restartedLaunch()).toBe('approving') + expect(await restarted._internals.settledVerdictForTesting()).toBe('unavailable') + // Why: the first timeout since the restart retries on the next launch. + expect(await restartedLaunch()).toBe('approving') + await restarted._internals.settledVerdictForTesting() + }) + + it('has one retry schedule: turning hooks off and on after a failure retries at once', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + const failing = installAppServer( + 0, + new Error('codex app-server exited before completing the session') + ) + failing.start() + expect(await launch()).toBe('approving') + expect(await realHomeInternals.settledVerdictForTesting()).toBe('unavailable') + + await ensureRealHomeCodexHookState({ + hooksEnabled: false, + userDataPath: homes.userDataDir, + writePolicy: 'add-missing-only' + }) + const recovered = installAppServer(0) + recovered.start() + expect(await launch()).toBe('approving') + expect(await realHomeInternals.settledVerdictForTesting()).toBe('installed') + expect(recovered.sessions).toBe(1) + }) + + it.skipIf(process.platform === 'win32')( + 'removes a retired entry and moves the user trust behind it with no Codex session', + async () => { + const hung = installAppServer(10 * 60_000) + hung.start() + const script = `'${join(homes.tmpHome, '.orca', 'agent-hooks', 'codex-hook.sh')}'` + const retired = { + hooks: [{ type: 'command', command: `if [ -x ${script} ]; then /bin/sh ${script}; fi` }] + } + writeFileSync( + hooksPath(), + `${JSON.stringify({ hooks: { Stop: [retired, USER_HOOK] } }, null, 2)}\n` + ) + const userAt = (groupIndex: number) => ({ + sourcePath: hooksPath(), + eventLabel: 'stop' as const, + groupIndex, + handlerIndex: 0, + command: 'user-hook.sh' + }) + upsertHookTrustEntries(configPath(), [{ ...userAt(1), trustedHash: 'sha256:user-approved' }]) + + expect(await launch()).toBe('approving') + expect(await realHomeInternals.settledVerdictForTesting()).toBe('unavailable') + await cleanupLegacySystemManagedHooks() + + expect(readHooks().Stop).toEqual([USER_HOOK]) + const trust = readHookTrustEntries(configPath()) + expect(trust.get(computeTrustKey(userAt(0)))?.trustedHash).toBe('sha256:user-approved') + // Why: the one session is the launch's grant; the removal started none. + expect(hung.sessions).toBe(1) + } + ) +}) diff --git a/src/main/codex/codex-real-home-trust-withdrawal.test.ts b/src/main/codex/codex-real-home-trust-withdrawal.test.ts new file mode 100644 index 000000000000..48447d7f0d00 --- /dev/null +++ b/src/main/codex/codex-real-home-trust-withdrawal.test.ts @@ -0,0 +1,243 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import type * as NodeOs from 'node:os' +import type * as EntryTrust from './codex-real-home-entry-trust' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { wrapPosixHookCommand, type HookDefinition } from '../agent-hooks/installer-utils' +import type { CodexManagedTrustGrantPlan } from './codex-hook-trust-grant' +import { computeTrustedHash, upsertHookTrustEntries } from './config-toml-trust' + +const { homedirMock, grantMock, beforeTrustRead } = vi.hoisted(() => { + // Why: the withdrawal's only step between its hooks.json read and write. + const beforeTrustRead: { run: (() => void) | null } = { run: null } + return { homedirMock: vi.fn<() => string>(), grantMock: vi.fn(), beforeTrustRead } +}) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { ...actual, homedir: homedirMock } +}) +vi.mock('./codex-real-home-entry-trust', async (importOriginal) => { + const actual = await importOriginal() + return { + readOrcaEntryTrust: (...args: Parameters) => { + const run = beforeTrustRead.run + beforeTrustRead.run = null + run?.() + return actual.readOrcaEntryTrust(...args) + } + } +}) +vi.mock('./codex-hook-trust-grant', () => ({ + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS: 300_000, + findCurrentManagedCodexHookTrust: async () => null, + grantManagedCodexHookTrust: grantMock +})) + +import { + ensureRealHomeCodexHookState as startRealHomeCodexHookEnsure, + _internals +} from './codex-real-home-hook-install' + +/** The lane once Codex's background approval, if any, has settled. */ +async function ensureRealHomeCodexHookState( + args: Parameters[0] +): ReturnType { + await startRealHomeCodexHookEnsure(args) + return _internals.settledVerdictForTesting() +} +import { getCodexManagedHookInstallMaterial } from './codex-hook-definition' + +// Why these tests: a failed trust session withdraws only what that call wrote and +// is still untrusted. Every Orca on this HOME shares the file, so anything else +// in it, including an identical entry another Orca trusted, must survive. + +let homeDir: string +let userDataDir: string +const SAVED_MEANWHILE = `${JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'saved-meanwhile.sh' }] }] } }, null, 2)}\n` + +function hooksJsonPath(): string { + return join(homeDir, '.codex', 'hooks.json') +} + +beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), 'orca-real-home-withdrawal-home-')) + userDataDir = mkdtempSync(join(tmpdir(), 'orca-real-home-withdrawal-user-data-')) + vi.stubEnv('ORCA_USER_DATA_PATH', userDataDir) + homedirMock.mockReturnValue(homeDir) + mkdirSync(join(homeDir, '.codex'), { recursive: true }) + _internals.resetForTesting('pending') + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + beforeTrustRead.run = null + vi.unstubAllEnvs() + vi.restoreAllMocks() + grantMock.mockReset() + rmSync(homeDir, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) +}) + +it('keeps a hooks.json save that lands during a failed real-home grant', async () => { + writeFileSync(hooksJsonPath(), `${JSON.stringify({ hooks: {} }, null, 2)}\n`) + grantMock.mockImplementation(() => { + writeFileSync(hooksJsonPath(), SAVED_MEANWHILE) + return { lane: 'fallback', reason: 'error' } + }) + + expect( + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: userDataDir, + writePolicy: 'add-missing-only' + }) + ).toBe('unavailable') + + expect(readFileSync(hooksJsonPath(), 'utf-8')).toBe(SAVED_MEANWHILE) +}) + +type HooksFile = { hooks: Record } + +function readHooks(): HooksFile { + return JSON.parse(readFileSync(hooksJsonPath(), 'utf-8')) +} + +function writeHooks(file: HooksFile): string { + const raw = `${JSON.stringify(file, null, 2)}\n` + writeFileSync(hooksJsonPath(), raw) + return raw +} + +function orcaCommands(file: HooksFile): string[] { + const { command } = getCodexManagedHookInstallMaterial() + return Object.values(file.hooks) + .flat() + .flatMap((definition) => definition.hooks ?? []) + .map((hook) => hook.command) + .filter((candidate) => candidate === command) +} + +const userHook = (command: string) => ({ hooks: [{ type: 'command' as const, command }] }) + +it('withdraws only what the failed call added; adds made during the session survive', async () => { + writeHooks({ hooks: { Stop: [userHook('before.sh')] } }) + grantMock.mockImplementation(() => { + const file = readHooks() + file.hooks.Stop!.push(userHook('appended-during-session.sh')) + file.hooks.PreCompact = [userHook('added-during-session.sh')] + writeHooks(file) + return { lane: 'fallback', reason: 'error' } + }) + + expect( + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: userDataDir, + writePolicy: 'add-missing-only' + }) + ).toBe('unavailable') + + expect(readHooks()).toEqual({ + hooks: { + Stop: [userHook('before.sh'), userHook('appended-during-session.sh')], + PreCompact: [userHook('added-during-session.sh')] + } + }) +}) + +it('keeps an entry another Orca trusted during the failed session', async () => { + grantMock.mockImplementation((plan: CodexManagedTrustGrantPlan) => { + const stop = plan.managedEntries.find((entry) => entry.eventLabel === 'stop')! + upsertHookTrustEntries(join(homeDir, '.codex', 'config.toml'), [ + { ...stop, trustedHash: computeTrustedHash(stop) } + ]) + return { lane: 'fallback', reason: 'error' } + }) + + expect( + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: userDataDir, + writePolicy: 'add-missing-only' + }) + ).toBe('unavailable') + + const file = readHooks() + expect(Object.keys(file.hooks)).toEqual(['Stop']) + expect(orcaCommands(file)).toHaveLength(1) +}) + +it.skipIf(process.platform === 'win32')( + "puts an older build's entry back in its slot when the one-time conversion cannot be trusted", + async () => { + const older = wrapPosixHookCommand(join(homeDir, '.orca', 'agent-hooks', 'codex-hook.sh')) + const { events } = getCodexManagedHookInstallMaterial() + const original = writeHooks({ + hooks: Object.fromEntries( + events.map((event) => [ + event, + [{ hooks: [{ type: 'command', command: older, timeout: 10 }] }, userHook('after.sh')] + ]) + ) + }) + grantMock.mockReturnValue({ lane: 'fallback', reason: 'error' }) + + expect( + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: userDataDir, + writePolicy: 'convert-older-forms' + }) + ).toBe('unavailable') + + expect(readFileSync(hooksJsonPath(), 'utf-8')).toBe(original) + } +) + +it.skipIf(process.platform === 'win32')( + 'withdraws the copy the failed conversion wrote, not an earlier frozen copy in a matcher group', + async () => { + const older = wrapPosixHookCommand(join(homeDir, '.orca', 'agent-hooks', 'codex-hook.sh')) + const { command, events } = getCodexManagedHookInstallMaterial() + const matched = { matcher: 'Bash', hooks: [{ type: 'command' as const, command }] } + const original = writeHooks({ + hooks: Object.fromEntries( + events.map((event) => [ + event, + [matched, userHook('between.sh'), { hooks: [{ type: 'command', command: older }] }] + ]) + ) + }) + grantMock.mockReturnValue({ lane: 'fallback', reason: 'error' }) + + expect( + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: userDataDir, + writePolicy: 'convert-older-forms' + }) + ).toBe('unavailable') + + expect(readFileSync(hooksJsonPath(), 'utf-8')).toBe(original) + } +) + +it('keeps a hooks.json save that lands between the withdrawal read and its write', async () => { + writeHooks({ hooks: {} }) + grantMock.mockReturnValue({ lane: 'fallback', reason: 'error' }) + beforeTrustRead.run = () => { + writeFileSync(hooksJsonPath(), SAVED_MEANWHILE) + } + + expect( + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: userDataDir, + writePolicy: 'add-missing-only' + }) + ).toBe('unavailable') + + expect(readFileSync(hooksJsonPath(), 'utf-8')).toBe(SAVED_MEANWHILE) +}) diff --git a/src/main/codex/codex-real-home-user-hook-positions.test.ts b/src/main/codex/codex-real-home-user-hook-positions.test.ts new file mode 100644 index 000000000000..3eb975178c26 --- /dev/null +++ b/src/main/codex/codex-real-home-user-hook-positions.test.ts @@ -0,0 +1,258 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import type * as Os from 'node:os' +import { join } from 'node:path' +import { wrapPosixHookCommand, type HookDefinition } from '../agent-hooks/installer-utils' +import type { CodexHookTrustGrantRequest } from './codex-app-server-client' +import { _internals as grantInternals } from './codex-hook-trust-grant' +import { createCodexHookTrustEntry } from './codex-hook-identity' +import { + computeTrustedHash, + normalizeHookTrustKeyForLookup, + computeTrustKey, + parseTrustKey, + readHookTrustEntries, + upsertHookTrustEntries, + upsertHookTrustEntriesInContent, + type CodexTrustEntry +} from './config-toml-trust' +import { isCodexManagedCommand, setupCodexHookHomes } from './hook-service-test-harness' + +const { getPathMock, homedirMock, resolveCodexCommandMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>(), + homedirMock: vi.fn<() => string>(), + resolveCodexCommandMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ app: { getPath: getPathMock } })) +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, homedir: homedirMock } +}) +vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock })) + +import { + _internals as realHomeInternals, + ensureRealHomeCodexHookState as startRealHomeCodexHookEnsure, + type RealHomeCodexHookWritePolicy +} from './codex-real-home-hook-install' + +/** The lane once Codex's background approval, if any, has settled. */ +async function ensureRealHomeCodexHookState( + args: Parameters[0] +): ReturnType { + await startRealHomeCodexHookEnsure(args) + return realHomeInternals.settledVerdictForTesting() +} +import { getCodexManagedHookInstallMaterial } from './codex-hook-definition' + +// Why this file: Codex keys a hook's trust by its position. Orca's automatic +// writes (add a missing entry, convert an older one, collapse duplicates) must +// leave every user hook at its position with its trust untouched; only an +// explicit opt-out removes an entry. + +const homes = setupCodexHookHomes(homedirMock, getPathMock) +const USER_A: HookDefinition = { hooks: [{ type: 'command', command: 'user-a.sh' }] } +const USER_B: HookDefinition = { hooks: [{ type: 'command', command: 'user-b.sh', timeout: 5 }] } +const USER_C: HookDefinition = { hooks: [{ type: 'command', command: 'user-c.sh' }] } + +type HooksFile = { hooks: Record } + +function hooksPath(): string { + return join(homes.tmpHome, '.codex', 'hooks.json') +} + +function configPath(): string { + return join(homes.tmpHome, '.codex', 'config.toml') +} + +function readHooks(): HooksFile { + return JSON.parse(readFileSync(hooksPath(), 'utf-8')) +} + +function orcaGroup(command: string): HookDefinition { + return { hooks: [{ type: 'command', command, timeout: 10 }] } +} + +function olderCommand(): string { + return wrapPosixHookCommand(join(homes.tmpHome, '.orca', 'agent-hooks', 'codex-hook.sh')) +} + +function frozenCommand(): string { + return getCodexManagedHookInstallMaterial().command +} + +/** Every user handler with its position; Orca's handlers are left out. */ +function userHooksByPosition(file: HooksFile): string[] { + return Object.entries(file.hooks).flatMap(([event, definitions]) => + definitions.flatMap((definition, groupIndex) => + (definition.hooks ?? []).flatMap((hook, handlerIndex) => + isCodexManagedCommand(hook.command) + ? [] + : [`${event}:${groupIndex}:${handlerIndex}:${hook.command}`] + ) + ) + ) +} + +/** Seeds hooks.json, plus config.toml with user trust: A trusted, B disabled, C untrusted. */ +function seed(file: HooksFile): string { + mkdirSync(join(homes.tmpHome, '.codex'), { recursive: true }) + writeFileSync(hooksPath(), `${JSON.stringify(file, null, 2)}\n`) + const userTrust: CodexTrustEntry[] = [] + for (const [event, definitions] of Object.entries(file.hooks)) { + definitions.forEach((definition, groupIndex) => { + definition.hooks?.forEach((hook, handlerIndex) => { + const entry = createCodexHookTrustEntry( + hooksPath(), + event, + groupIndex, + handlerIndex, + definition, + hook + ) + if (entry && hook.command === 'user-a.sh') { + userTrust.push(entry) + } + if (entry && hook.command === 'user-b.sh') { + userTrust.push({ ...entry, enabled: false }) + } + }) + }) + } + const userToml = upsertHookTrustEntriesInContent('model = "user-model"\n', userTrust) + writeFileSync(configPath(), userToml) + return userToml +} + +/** Codex's own grant: trusts the listed Orca keys, writing only those blocks. */ +function installCodexLikeGrant(): void { + grantInternals.setGrantSessionRunner(async (request: CodexHookTrustGrantRequest) => { + const entries = request.expectedTrustKeys.map((key) => { + const entry = { ...parseTrustKey(key)!, command: request.managedCommand, timeoutSec: 10 } + return { key, entry, trustedHash: computeTrustedHash(entry) } + }) + upsertHookTrustEntries( + configPath(), + entries.map(({ entry, trustedHash }) => ({ ...entry, trustedHash })) + ) + return { + outcome: 'granted' as const, + wroteTrust: true, + entries: entries.map(({ key, trustedHash }) => ({ + key, + normalizedKey: normalizeHookTrustKeyForLookup(key), + trustedHash + })) + } + }) +} + +async function expectUserHooksUntouched( + file: HooksFile, + policy: RealHomeCodexHookWritePolicy +): Promise { + const userToml = seed(file) + const before = userHooksByPosition(file) + + expect( + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: homes.userDataDir, + writePolicy: policy + }) + ).toBe('installed') + + const after = readHooks() + expect(userHooksByPosition(after)).toEqual(before) + // Why a prefix: Orca's trust blocks are only ever appended after the user's bytes. + expect(readFileSync(configPath(), 'utf-8').startsWith(userToml)).toBe(true) + return after +} + +function everyEvent(groups: (command: string) => HookDefinition[]): HooksFile { + const { events } = getCodexManagedHookInstallMaterial() + return { hooks: Object.fromEntries(events.map((event) => [event, groups(event)])) } +} + +beforeEach(() => { + realHomeInternals.resetForTesting('pending') + resolveCodexCommandMock.mockReturnValue(process.execPath) + installCodexLikeGrant() + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +describe.skipIf(process.platform === 'win32')('user hooks under Orca writes', () => { + it('keeps positions and trust when a launch appends the missing entry', async () => { + const after = await expectUserHooksUntouched( + everyEvent(() => [USER_A, USER_B, USER_C]), + 'add-missing-only' + ) + + expect(after.hooks.Stop).toEqual([USER_A, USER_B, USER_C, orcaGroup(frozenCommand())]) + }) + + it('keeps positions and trust when app start converts an older entry in place', async () => { + const after = await expectUserHooksUntouched( + everyEvent(() => [USER_A, orcaGroup(olderCommand()), USER_B, USER_C]), + 'convert-older-forms' + ) + + expect(after.hooks.Stop).toEqual([USER_A, orcaGroup(frozenCommand()), USER_B, USER_C]) + }) + + it('keeps positions and trust when app start collapses a trailing duplicate', async () => { + const after = await expectUserHooksUntouched( + everyEvent(() => [ + USER_A, + orcaGroup(frozenCommand()), + USER_B, + USER_C, + orcaGroup(frozenCommand()) + ]), + 'convert-older-forms' + ) + + expect(after.hooks.Stop).toEqual([USER_A, orcaGroup(frozenCommand()), USER_B, USER_C]) + }) + + it('keeps a duplicate that a user hook follows, and trusts both copies', async () => { + const file = everyEvent(() => [ + USER_A, + orcaGroup(frozenCommand()), + USER_B, + orcaGroup(frozenCommand()), + USER_C + ]) + + const after = await expectUserHooksUntouched(file, 'convert-older-forms') + + expect(after).toEqual(file) + const trust = readHookTrustEntries(configPath()) + for (const groupIndex of [1, 3]) { + const entry = createCodexHookTrustEntry( + hooksPath(), + 'Stop', + groupIndex, + 0, + after.hooks.Stop![groupIndex]!, + after.hooks.Stop![groupIndex]!.hooks![0]! + )! + expect(trust.get(computeTrustKey(entry))?.trustedHash).toBe(computeTrustedHash(entry)) + } + }) + + it('keeps positions and trust when app start normalizes older duplicates to one entry', async () => { + const after = await expectUserHooksUntouched( + everyEvent(() => [ + orcaGroup(olderCommand()), + USER_A, + { hooks: [USER_B.hooks![0]!, { type: 'command', command: olderCommand(), timeout: 10 }] }, + USER_C + ]), + 'convert-older-forms' + ) + + expect(after.hooks.Stop).toEqual([orcaGroup(frozenCommand()), USER_A, USER_B, USER_C]) + }) +}) diff --git a/src/main/codex/codex-structured-child-work-producer.test.ts b/src/main/codex/codex-structured-child-work-producer.test.ts index 611b7f75b57f..4317b5669d77 100644 --- a/src/main/codex/codex-structured-child-work-producer.test.ts +++ b/src/main/codex/codex-structured-child-work-producer.test.ts @@ -1,6 +1,7 @@ // A Codex session's frames, through the real adapter, into the host's child records: the order the -// host receives them in, and whether the parent row the records imply is today's row. +// host receives them in, and the parent row they fold to, written out step by step. +import { isDeepStrictEqual } from 'node:util' import { describe, expect, it } from 'vitest' import { foldAgentLeadStatus, @@ -40,10 +41,10 @@ const TESTER = 'thread-tester' const LINTER = 'thread-linter' type Frame = { method: string; params: Record } -type Delivery = { kind: 'journal' | 'legacy' | 'evidence'; detail: string } +type Delivery = { kind: 'journal' | 'publish' | 'evidence'; detail: string } type Liveness = ReturnType -/** What the records and today's strip each said when the journal wrote or published a row. */ -type JournalMoment = { recorded: Liveness; legacy: Liveness } +/** What the records said when the journal wrote or published a row. */ +type JournalMoment = { recorded: Liveness; commandLive: boolean } const turn = ( method: 'turn/started' | 'turn/completed', @@ -99,7 +100,9 @@ async function producer() { const moment = () => moments.push({ recorded: recordedLiveness(), - legacy: agentChildWorkLiveness(adapter.backgroundTaskState('session-1')?.tasks) + commandLive: records().some( + (record) => record.kind === 'command' && record.membership === 'live' + ) }) const adapter = new CodexStructuredSessionAdapter({ resolveLaunch: async () => ({ @@ -112,8 +115,6 @@ async function producer() { openConnection: codex.openConnection, readProcessStartTime: async () => 1_700_000_000_000, now: () => 1_700_000_000_500, - onBackgroundTasksChanged: (_sessionId, state) => - deliveries.push({ kind: 'legacy', detail: String(state?.tasks?.length ?? 0) }), onChildWorkEvidence: (sessionId, evidence) => { expect(sessionId).toBe('session-1') deliveries.push({ kind: 'evidence', detail: evidence.map((edge) => edge.type).join(',') }) @@ -128,7 +129,11 @@ async function producer() { moment() }, appendTombstone: () => {}, - publish: moment + // Production's journal publication is what republishes the parent's own row. + publish: () => { + deliveries.push({ kind: 'publish', detail: '' }) + moment() + } } await adapter.acquire({ identity: identityFor('session-1'), @@ -178,8 +183,6 @@ const fold = ( leadState: 'working' | 'done', childWorkLiveness: Liveness ): AgentLeadStatusResolution => foldAgentLeadStatus({ leadState, childWorkLiveness }) -/** The strip has no word for a child waiting on a human: to it, that child is working. */ -const asStrip = (liveness: Liveness): Liveness => (liveness === 'waiting' ? 'working' : liveness) const shellFrame = ( method: 'item/started' | 'item/completed', threadId: string, @@ -198,7 +201,7 @@ const shellFrame = ( }) describe('Codex structured child-work producer', () => { - it('delivers evidence only after the journal wrote the frame and the legacy row republished', async () => { + it('delivers evidence only after the journal wrote and published the frame', async () => { const { send, records } = await producer() send(turn('turn/started', THREAD_ID, 'p1')) send(turn('turn/started', REVIEWER, 'r1')) @@ -206,34 +209,51 @@ describe('Codex structured child-work producer', () => { const kinds = deliveries.map((delivery) => delivery.kind) // The frame's own rows, then the parent's republished row, and only then its children. expect(kinds.filter((kind) => kind === 'journal').length).toBeGreaterThan(0) - expect(kinds.slice(kinds.indexOf('legacy'))).toEqual(['legacy', 'evidence']) + expect(kinds.lastIndexOf('publish')).toBeGreaterThan(kinds.lastIndexOf('journal')) + expect(kinds.at(-1)).toBe('evidence') + expect(kinds.filter((kind) => kind === 'evidence')).toHaveLength(1) expect(records()).toEqual([ expect.objectContaining({ description: 'review', membership: 'live' }) ]) }) - it('records the parent state today reads, at every journal write, while adding outcome and activity', async () => { + it("folds the parent from the records, at every journal write, with each child's outcome and activity", async () => { const { adapter, momentsOf, records, recordedLiveness, byDescription, display } = await producer() const steps: { frame: Frame lead: 'working' | 'done' - childWaits?: true + live: Liveness + parent: AgentLeadStatusResolution check?: () => void }[] = [ - { frame: turn('turn/started', THREAD_ID, 'p1'), lead: 'working' }, + { + frame: turn('turn/started', THREAD_ID, 'p1'), + lead: 'working', + live: null, + parent: { stateName: 'working' } + }, // Codex reports the child's turn before its announcement. { frame: turn('turn/started', REVIEWER, 'r1'), lead: 'working', + live: null, + parent: { stateName: 'working' }, check: () => expect(records()).toEqual([]) }, - { frame: spawned(REVIEWER, 'review', 'p1'), lead: 'working' }, + { + frame: spawned(REVIEWER, 'review', 'p1'), + lead: 'working', + live: 'working', + parent: { stateName: 'working' } + }, // An approved command: Codex starts it on the approval path and reports its exit from // unified exec. { frame: shellFrame('item/started', REVIEWER, 'r1', 'cmd-1', 'npm test', 'agent'), lead: 'working', + live: 'working', + parent: { stateName: 'working' }, check: () => { expect(byDescription('review')?.operation).toMatchObject({ toolName: 'Bash', @@ -250,6 +270,8 @@ describe('Codex structured child-work producer', () => { { frame: shellFrame('item/started', REVIEWER, 'r1', 'exec-1', 'npm run dev'), lead: 'working', + live: 'working', + parent: { stateName: 'working' }, check: () => expect(byDescription('npm run dev')).toMatchObject({ membership: 'live', @@ -259,6 +281,8 @@ describe('Codex structured child-work producer', () => { { frame: shellFrame('item/completed', REVIEWER, 'r1', 'cmd-1', 'npm test'), lead: 'working', + live: 'working', + parent: { stateName: 'working' }, check: () => { // A finished command leaves nothing behind. expect(byDescription('npm test')).toBeUndefined() @@ -275,32 +299,39 @@ describe('Codex structured child-work producer', () => { id: 'msg-1', text: 'Dev server is up' }), - lead: 'working' + lead: 'working', + live: 'working', + parent: { stateName: 'working' } }, // The parent's turn ends first; its child keeps running. { frame: turn('turn/completed', THREAD_ID, 'p1'), lead: 'done', + live: 'working', + parent: { stateName: 'working' }, check: () => expect(byDescription('review')).toMatchObject({ membership: 'live', state: 'working' }) }, - // The legacy task list carries no child state, so only the records can say a child waits, - // and the shared fold ranks that wait above the parent's own state. + // Only the records say a child waits, and the shared fold ranks that wait above the parent's + // own state. { frame: status(REVIEWER, ['waitingOnApproval']), lead: 'done', - childWaits: true, - check: () => { - expect(byDescription('review')?.state).toBe('waiting') - expect(agentChildWorkLiveness(adapter.backgroundTaskState('session-1')?.tasks)).toBe( - 'working' - ) - } + live: 'waiting', + parent: { stateName: 'waiting' }, + check: () => expect(byDescription('review')?.state).toBe('waiting') + }, + { + frame: status(REVIEWER, []), + lead: 'done', + live: 'working', + parent: { stateName: 'working' } }, - { frame: status(REVIEWER, []), lead: 'done' }, { frame: turn('turn/completed', REVIEWER, 'r1'), lead: 'done', + live: 'monitoring', + parent: { stateName: 'working', workingMode: 'monitoring' }, check: () => { expect(byDescription('review')).toMatchObject({ membership: 'settled', @@ -315,11 +346,18 @@ describe('Codex structured child-work producer', () => { expect(display('review')).toBe('monitoring') } }, - { frame: turn('turn/started', THREAD_ID, 'p2'), lead: 'working' }, + { + frame: turn('turn/started', THREAD_ID, 'p2'), + lead: 'working', + live: 'monitoring', + parent: { stateName: 'working' } + }, // The parent asks the finished child a follow-up: the same record, a new run. { frame: turn('turn/started', REVIEWER, 'r2'), lead: 'working', + live: 'working', + parent: { stateName: 'working' }, check: () => expect(byDescription('review')).toMatchObject({ childWorkId: 'child-1', @@ -328,10 +366,30 @@ describe('Codex structured child-work producer', () => { previousInvocations: [expect.objectContaining({ outcome: 'succeeded' })] }) }, - { frame: spawned(TESTER, 'test', 'p2'), lead: 'working' }, - { frame: turn('turn/started', TESTER, 't1'), lead: 'working' }, - { frame: spawned(LINTER, 'lint', 'p2'), lead: 'working' }, - { frame: turn('turn/started', LINTER, 'l1'), lead: 'working' }, + { + frame: spawned(TESTER, 'test', 'p2'), + lead: 'working', + live: 'working', + parent: { stateName: 'working' } + }, + { + frame: turn('turn/started', TESTER, 't1'), + lead: 'working', + live: 'working', + parent: { stateName: 'working' } + }, + { + frame: spawned(LINTER, 'lint', 'p2'), + lead: 'working', + live: 'working', + parent: { stateName: 'working' } + }, + { + frame: turn('turn/started', LINTER, 'l1'), + lead: 'working', + live: 'working', + parent: { stateName: 'working' } + }, // Codex ends this child's turn with an error it will not retry, then a failed completion. { frame: { @@ -339,33 +397,48 @@ describe('Codex structured child-work producer', () => { params: { threadId: LINTER, turnId: 'l1', willRetry: false, error: { message: 'boom' } } }, lead: 'working', + live: 'working', + parent: { stateName: 'working' }, check: () => expect(byDescription('lint')).toMatchObject({ membership: 'live' }) }, { frame: turn('turn/completed', LINTER, 'l1', 'failed'), lead: 'working', + live: 'working', + parent: { stateName: 'working' }, check: () => expect(byDescription('lint')).toMatchObject({ membership: 'settled', outcome: 'failed' }) }, { frame: turn('turn/completed', REVIEWER, 'r2', 'interrupted'), lead: 'working', + live: 'working', + parent: { stateName: 'working' }, check: () => expect(byDescription('review')).toMatchObject({ membership: 'settled', outcome: 'cancelled' }) }, - { frame: turn('turn/completed', THREAD_ID, 'p2'), lead: 'done' }, + { + frame: turn('turn/completed', THREAD_ID, 'p2'), + lead: 'done', + live: 'working', + parent: { stateName: 'working' } + }, { frame: turn('turn/completed', TESTER, 't1', 'failed'), lead: 'done', + live: 'monitoring', + parent: { stateName: 'working', workingMode: 'monitoring' }, check: () => expect(byDescription('test')).toMatchObject({ membership: 'settled', outcome: 'failed' }) }, { frame: shellFrame('item/completed', REVIEWER, 'r1', 'exec-1', 'npm run dev'), lead: 'done', + live: null, + parent: { stateName: 'done' }, check: () => { expect(byDescription('npm run dev')).toBeUndefined() expect(display('review')).toBe('interrupted') @@ -373,24 +446,32 @@ describe('Codex structured child-work producer', () => { } ] let journalMoments = 0 + let before = steps[0].parent for (const [index, step] of steps.entries()) { const frameMoments = momentsOf(step.frame) journalMoments += frameMoments.length - for (const [at, { recorded, legacy }] of frameMoments.entries()) { - expect({ index, at, parent: fold(step.lead, asStrip(recorded)) }).toEqual({ + // Every row the journal publishes mid-frame reads the parent from before the frame or after + // it, and never done while a command still runs. + for (const [at, { recorded, commandLive }] of frameMoments.entries()) { + const parent = fold(step.lead, recorded) + expect({ + index, + at, + parent: [before, step.parent].some((p) => isDeepStrictEqual(p, parent)) + }).toEqual({ index, at, parent: true }) + expect({ index, at, done: commandLive && parent.stateName === 'done' }).toEqual({ index, at, - parent: fold(step.lead, legacy) + done: false }) } - const legacy = agentChildWorkLiveness(adapter.backgroundTaskState('session-1')?.tasks) - const recorded = recordedLiveness() - const expected = step.childWaits ? 'waiting' : legacy - expect({ index, parent: fold(step.lead, recorded) }).toEqual({ + const live = recordedLiveness() + expect({ index, live, parent: fold(step.lead, live) }).toEqual({ index, - parent: fold(step.lead, expected) + live: step.live, + parent: step.parent }) - expect({ index, liveness: recorded }).toEqual({ index, liveness: expected }) + before = step.parent step.check?.() } expect(journalMoments).toBeGreaterThan(steps.length) diff --git a/src/main/codex/codex-structured-dispatch-admission.test.ts b/src/main/codex/codex-structured-dispatch-admission.test.ts index b3c8a3fefeb7..80bf0cee097d 100644 --- a/src/main/codex/codex-structured-dispatch-admission.test.ts +++ b/src/main/codex/codex-structured-dispatch-admission.test.ts @@ -45,11 +45,10 @@ function lifecycleRecorder(): { describe('codex dispatch admission', () => { it('admits a send queued behind a running turn and settles it when Codex echoes it', async () => { - // Measured on codex-cli 0.153.4: a `turn/start` issued while a turn runs is - // COALESCED into it -- same turn id back, no second `turn/started`, and the - // user message echoed only once the running turn reaches it. + // A send while a turn runs is steered into it: that turn's id back, no second + // `turn/started`, and the user message echoed only once the running turn reaches it. const codex = fakeCodexAppServer({ - 'turn/start': () => ({ turn: { id: 'turn-1', status: 'inProgress' } }) + 'turn/steer': () => ({ turnId: 'turn-1' }) }) const settlements: LateSettlement[] = [] const adapter = await acquiredCodexAdapter({ codex, settlements }) @@ -82,7 +81,7 @@ describe('codex dispatch admission', () => { }) it('correlates each send by client message id, not queue order', async () => { - const codex = fakeCodexAppServer({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) + const codex = fakeCodexAppServer({ 'turn/steer': () => ({ turnId: 'turn-1' }) }) const settlements: LateSettlement[] = [] const adapter = await acquiredCodexAdapter({ codex, settlements }) const connection = codex.connections[0]! @@ -122,7 +121,7 @@ describe('codex dispatch admission', () => { }) it('settles nothing for a user message this session never sent', async () => { - const codex = fakeCodexAppServer({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) + const codex = fakeCodexAppServer({ 'turn/steer': () => ({ turnId: 'turn-1' }) }) const settlements: LateSettlement[] = [] const adapter = await acquiredCodexAdapter({ codex, settlements }) const connection = codex.connections[0]! @@ -152,7 +151,6 @@ describe('codex dispatch admission', () => { const settlements: LateSettlement[] = [] const adapter = await acquiredCodexAdapter({ codex, settlements }) const connection = codex.connections[0]! - startTurn(connection, 'turn-1') // Codex's own words reach the sentence and the fact; Orca's prefix reaches neither. expect(await send(adapter, 'client-1')).toEqual({ @@ -165,6 +163,7 @@ describe('codex dispatch admission', () => { }) // A refused write is disarmed, so a later echo of that id settles nothing. + startTurn(connection, 'turn-1') echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' }) expect(settlements).toEqual([]) }) @@ -178,9 +177,9 @@ describe('codex dispatch admission', () => { const settlements: LateSettlement[] = [] const adapter = await acquiredCodexAdapter({ codex, settlements }) const connection = codex.connections[0]! - startTurn(connection, 'turn-1') await expect(send(adapter, 'client-1')).rejects.toThrow('request timed out after write') + startTurn(connection, 'turn-1') echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' }) expect(settlements).toEqual([ @@ -250,7 +249,7 @@ describe('codex dispatch admission', () => { it('does not attribute a send armed after an autonomous turn started', async () => { const codex = fakeCodexAppServer({ - 'turn/start': () => ({ turn: { id: 'turn-resumed', status: 'inProgress' } }) + 'turn/steer': () => ({ turnId: 'turn-resumed' }) }) const settlements: LateSettlement[] = [] const recorded = lifecycleRecorder() @@ -274,7 +273,8 @@ describe('codex dispatch admission', () => { it('keeps the earliest dispatched origin across out-of-order echoes and a clock step', async () => { const codex = fakeCodexAppServer({ - 'turn/start': () => ({ turn: { id: 'turn-1', status: 'inProgress' } }) + 'turn/start': () => ({ turn: { id: 'turn-1', status: 'inProgress' } }), + 'turn/steer': () => ({ turnId: 'turn-1' }) }) const settlements: LateSettlement[] = [] const recorded = lifecycleRecorder() @@ -350,7 +350,7 @@ describe('codex dispatch admission', () => { }) it('refuses overflow without discarding an older accepted send', async () => { - const codex = fakeCodexAppServer({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) + const codex = fakeCodexAppServer({ 'turn/steer': () => ({ turnId: 'turn-1' }) }) const settlements: LateSettlement[] = [] const adapter = await acquiredCodexAdapter({ codex, settlements }) const connection = codex.connections[0]! @@ -370,7 +370,7 @@ describe('codex dispatch admission', () => { }) it('leaves no waiter behind when the session closes', async () => { - const codex = fakeCodexAppServer({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) + const codex = fakeCodexAppServer({ 'turn/steer': () => ({ turnId: 'turn-1' }) }) const settlements: LateSettlement[] = [] const adapter = await acquiredCodexAdapter({ codex, settlements }) const connection = codex.connections[0]! @@ -384,7 +384,7 @@ describe('codex dispatch admission', () => { }) it('leaves no waiter behind when the child exits', async () => { - const codex = fakeCodexAppServer({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) + const codex = fakeCodexAppServer({ 'turn/steer': () => ({ turnId: 'turn-1' }) }) const settlements: LateSettlement[] = [] const adapter = await acquiredCodexAdapter({ codex, settlements }) const connection = codex.connections[0]! diff --git a/src/main/codex/codex-structured-dispatch-echo.test.ts b/src/main/codex/codex-structured-dispatch-echo.test.ts index a6791e5e788d..c7eb43bf96f4 100644 --- a/src/main/codex/codex-structured-dispatch-echo.test.ts +++ b/src/main/codex/codex-structured-dispatch-echo.test.ts @@ -158,6 +158,18 @@ describe('the turn Codex answered a send into but has not opened', () => { expect(echoes.answeredUnopenedTurn('thread-1', NONE_OPEN)).toBeNull() }) + it('skips a turn a wait left unopened, and still names an earlier one', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1') + echoes.arm('client-2') + echoes.bindTurn('client-2', 'thread-1', 'turn-2') + + echoes.leftUnopened('thread-1', 'turn-2') + + expect(echoes.answeredUnopenedTurn('thread-1', NONE_OPEN)).toBe('turn-1') + }) + it('is none for a send not yet answered, or answered on another thread', () => { const echoes = createCodexDispatchEchoes() echoes.arm('client-1') diff --git a/src/main/codex/codex-structured-dispatch-echo.ts b/src/main/codex/codex-structured-dispatch-echo.ts index ef84501adcb2..775cac5bca4f 100644 --- a/src/main/codex/codex-structured-dispatch-echo.ts +++ b/src/main/codex/codex-structured-dispatch-echo.ts @@ -4,7 +4,8 @@ import type { AgentJournalItemIdentity } from '../../shared/agent-session-journa /** Sends awaiting their echo. One bound to a turn that ended without taking it settles from that * end; any other whose echo never arrives is retired by the journal's recovery on exit. */ export const MAX_CODEX_PENDING_DISPATCH_ECHOES = 256 -/** Turn ends kept for an answer read after the turn it names had already ended. */ +/** Turn ends kept for an answer read after the turn it names had already ended, and answered + * turns kept that Codex left unopened through a wait. */ export const MAX_CODEX_RECORDED_TURN_ENDS = 64 /** How a primary-thread turn ended, as Codex reported it. */ @@ -38,9 +39,11 @@ export type CodexDispatchEchoes = { * read after it; a send that end settles is no longer armed. */ bindTurn: (clientMessageId: string, threadId: string, turnId: string) => CodexTurnEnd | null - /** The turn the latest armed send was answered into that is neither in `openTurnIds` nor ended: - * one Codex has picked for the send but not opened. */ + /** The turn the latest armed send was answered into that is neither in `openTurnIds`, ended, nor + * left unopened through a wait: one Codex has picked for the send but not opened. */ answeredUnopenedTurn: (threadId: string, openTurnIds: ReadonlySet) => string | null + /** Codex did not open this answered turn within a wait, so no later wait is spent on it. */ + leftUnopened: (threadId: string, turnId: string) => void /** * Records a turn's end and returns the sends bound to it that it settles: all of them unless it * completed, which echoes its pending input first, so one it never echoed waits for recovery. An @@ -61,6 +64,7 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { { requestedAt: number | null; sequence: number; turn?: { threadId: string; turnId: string } } >() const endedTurns = new Map() + const unopenedTurns = new Set() let nextSequence = 0 const turnKey = (threadId: string, turnId: string): string => JSON.stringify([threadId, turnId]) const settles = (end: CodexTurnEnd): boolean => end.status !== 'completed' @@ -97,12 +101,22 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { const answered = [...armed.values()].flatMap(({ turn }) => turn?.threadId === threadId && !openTurnIds.has(turn.turnId) && - !endedTurns.has(turnKey(threadId, turn.turnId)) + !endedTurns.has(turnKey(threadId, turn.turnId)) && + !unopenedTurns.has(turnKey(threadId, turn.turnId)) ? [turn.turnId] : [] ) return answered.at(-1) ?? null }, + leftUnopened: (threadId, turnId) => { + unopenedTurns.add(turnKey(threadId, turnId)) + for (const oldest of unopenedTurns) { + if (unopenedTurns.size <= MAX_CODEX_RECORDED_TURN_ENDS) { + break + } + unopenedTurns.delete(oldest) + } + }, endTurn: (threadId, turnId, end) => { const turn = turnKey(threadId, turnId) endedTurns.delete(turn) @@ -136,6 +150,7 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { clear: () => { armed.clear() endedTurns.clear() + unopenedTurns.clear() nextSequence = 0 }, get size() { diff --git a/src/main/codex/codex-structured-dispatch-test-support.ts b/src/main/codex/codex-structured-dispatch-test-support.ts index 2576c775e214..7513ec1fbbe4 100644 --- a/src/main/codex/codex-structured-dispatch-test-support.ts +++ b/src/main/codex/codex-structured-dispatch-test-support.ts @@ -9,6 +9,7 @@ import type { CodexAppServerLaunch, openCodexAppServerConnection } from './codex-app-server-connection' +import { CodexAppServerUnsupportedError } from './codex-app-server-session' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' import { codexTurnLifecycleFake } from './codex-turn-lifecycle-fake' @@ -35,6 +36,13 @@ export type LateSettlement = Parameters< NonNullable >[0] +/** A Codex with no `turn/steer`, unless the test routes one. */ +export const refuseUnroutedSteer: CodexTestRoute = () => { + throw new CodexAppServerUnsupportedError( + 'codex app-server does not support turn/steer: method not found' + ) +} + /** A `codex app-server` whose turn traffic the test drives by hand. */ export function fakeCodexAppServer(routes: Record = {}): { connections: FakeConnection[] @@ -42,7 +50,7 @@ export function fakeCodexAppServer(routes: Record = {}): routes: Record } { const connections: FakeConnection[] = [] - const openConnection = (async (launch, handlers = {}) => { + const openConnection: typeof openCodexAppServerConnection = async (launch, handlers = {}) => { const connection: FakeConnection = { launch, handlers, @@ -51,7 +59,8 @@ export function fakeCodexAppServer(routes: Record = {}): closed: false, request: async (method, params) => { connection.calls.push({ method, params }) - return routes[method]?.(params) ?? {} + const route = routes[method] ?? (method === 'turn/steer' ? refuseUnroutedSteer : undefined) + return route?.(params) ?? {} }, notify: () => {}, respond: () => {}, @@ -63,7 +72,7 @@ export function fakeCodexAppServer(routes: Record = {}): } connections.push(connection) return connection - }) as typeof openCodexAppServerConnection + } routes['thread/start'] ??= () => ({ thread: { id: CODEX_TEST_THREAD_ID, path: '/rollouts/abc.jsonl' } }) @@ -152,14 +161,17 @@ export async function openAfterTurnStarts( } /** An acquired adapter over a fake Codex that keeps Codex's own turn bookkeeping. */ -export async function codexTurnLifecycleRig(options: { requestTimeoutMs?: number } = {}) { +export async function codexTurnLifecycleRig( + options: { requestTimeoutMs?: number; legacyStartAnswers?: boolean } = {} +) { + const { legacyStartAnswers, ...adapterOptions } = options const codex = fakeCodexAppServer() const notify = (method: string, params: unknown): void => codex.connections.at(-1)?.handlers.onNotification?.(method, params) - const turns = codexTurnLifecycleFake(CODEX_TEST_THREAD_ID, () => notify) + const turns = codexTurnLifecycleFake(CODEX_TEST_THREAD_ID, () => notify, { legacyStartAnswers }) Object.assign(codex.routes, turns.routes) const settlements: LateSettlement[] = [] - const adapter = await acquiredCodexAdapter({ codex, settlements, ...options }) + const adapter = await acquiredCodexAdapter({ codex, settlements, ...adapterOptions }) const send = (clientMessageId: string) => adapter.dispatch({ sessionId: 'session-1', diff --git a/src/main/codex/codex-structured-fast-mode.test.ts b/src/main/codex/codex-structured-fast-mode.test.ts index 73f279bd2c0f..5a40d622af0a 100644 --- a/src/main/codex/codex-structured-fast-mode.test.ts +++ b/src/main/codex/codex-structured-fast-mode.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { + THREAD_ID, USER_MESSAGE, adapterFor, answerWithOpenedTurn, @@ -133,6 +134,11 @@ describe('Codex structured Fast mode dispatch', () => { fastModeSupport: { supported: true }, current: { fastMode: true } }) + // Ended, so the next send starts a turn rather than steering into this one. + codex.connections[0].handlers.onNotification?.('turn/completed', { + threadId: THREAD_ID, + turn: { id: 'turn-recovered', status: 'completed' } + }) await adapter.dispatch({ sessionId: 'session-1', clientMessageId: 'client-recovered', diff --git a/src/main/codex/codex-structured-item-translation.ts b/src/main/codex/codex-structured-item-translation.ts index 34f07eefda7e..25b821d90d9f 100644 --- a/src/main/codex/codex-structured-item-translation.ts +++ b/src/main/codex/codex-structured-item-translation.ts @@ -9,6 +9,11 @@ import { import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' import { codexImageItemBody } from './codex-image-item-translation' import { commandActionFacts } from './codex-command-action-class' +import { + codexCollabAgentToolCallBody, + type CodexHelperName +} from './codex-collab-agent-item-translation' +import { codexItemRunState } from './codex-item-run-state' import { readFirstString, readRecord, @@ -66,20 +71,6 @@ export function codexMessageBlocks(item: CodexThreadItem): NativeChatBlock[] { return blocks } -/** Codex reports `inProgress` then a terminal status; a zero exit code is the - * only thing that makes a finished command a success. */ -function commandState(item: CodexThreadItem): 'running' | 'completed' | 'failed' { - const status = readString(item, 'status') - if (status === null || status === 'inProgress') { - return 'running' - } - if (status !== 'completed') { - return 'failed' - } - const exitCode = item.exitCode - return typeof exitCode === 'number' && exitCode !== 0 ? 'failed' : 'completed' -} - export type CodexJournalItem = { body: AgentJournalItemBody | null handled: boolean @@ -103,7 +94,7 @@ function commandItem(item: CodexThreadItem): CodexJournalItem { { command: item.command ?? null, cwd: item.cwd ?? null, ...parsed?.fields }, DEFAULT_JOURNAL_PAYLOAD_LIMITS ), - state: commandState(item), + state: codexItemRunState(item), ...toolExecutionMetadata(item), ...(bounded === null ? {} : { output: bounded.bounded }) }, @@ -127,7 +118,7 @@ function fileChangeItem(item: CodexThreadItem): CodexJournalItem { name: 'apply_patch', callId: item.id, input: boundToolInput({ changes: item.changes ?? null }, DEFAULT_JOURNAL_PAYLOAD_LIMITS), - state: commandState(item) + state: codexItemRunState(item) }, handled: true } @@ -180,7 +171,7 @@ function mcpToolCallItem(item: CodexThreadItem): CodexJournalItem { callId: item.id, ...(server && tool ? { mcpIdentity: { server, tool } } : {}), input: boundToolInput(mcpToolArguments(item.arguments), DEFAULT_JOURNAL_PAYLOAD_LIMITS), - state: failure === null ? commandState(item) : 'failed', + state: failure === null ? codexItemRunState(item) : 'failed', ...(bounded === null ? {} : { output: bounded.bounded }) }, handled: true @@ -234,9 +225,14 @@ function webSearchItem(item: CodexThreadItem): CodexJournalItem { * Journal body for a Codex item, or null for one with nothing to render. * * Known empty items wait for later deltas. Unknown types become bounded status - * rows so a provider release cannot make new activity invisible. + * rows so a provider release cannot make new activity invisible. `started` is a + * finished item's own started revision, when the caller still holds it. */ -export function codexJournalItem(item: CodexThreadItem): CodexJournalItem { +export function codexJournalItem( + item: CodexThreadItem, + helperName?: CodexHelperName, + started?: CodexThreadItem +): CodexJournalItem { if (item.type === 'userMessage' || item.type === 'agentMessage') { const blocks = codexMessageBlocks(item) return { @@ -262,6 +258,10 @@ export function codexJournalItem(item: CodexThreadItem): CodexJournalItem { if (item.type === 'imageView' || item.type === 'imageGeneration') { return { body: codexImageItemBody(item), handled: true } } + const collab = codexCollabAgentToolCallBody(item, helperName, started) + if (collab) { + return { body: collab, handled: true } + } if (item.type === 'plan') { const text = readTextContent(item, 'text') return { diff --git a/src/main/codex/codex-structured-journal-contracts.ts b/src/main/codex/codex-structured-journal-contracts.ts index 74cf5013aff0..af7c1ed3035d 100644 --- a/src/main/codex/codex-structured-journal-contracts.ts +++ b/src/main/codex/codex-structured-journal-contracts.ts @@ -8,6 +8,8 @@ import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-sess export type CodexJournalTranslatorDeps = { sink: StructuredAgentSessionEventSink + /** Names this connection in frame-row identities, so a later connection never revises its rows. */ + acquisitionId?: string /** Keys restored lifecycle rows to the live identity; without it history restore skips them. */ sessionId?: string now?: () => number diff --git a/src/main/codex/codex-structured-journal-generic-frames.ts b/src/main/codex/codex-structured-journal-generic-frames.ts index a8b400a6d93d..bdbed8717aeb 100644 --- a/src/main/codex/codex-structured-journal-generic-frames.ts +++ b/src/main/codex/codex-structured-journal-generic-frames.ts @@ -1,4 +1,10 @@ -import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' +import type { AgentJournalStatusItem } from '../../shared/agent-session-journal-types' +import { + unhandledProviderFrameJournalItem, + type UnhandledProviderFrameJournalItem, + type UnhandledProviderFrameJournalItemOptions +} from '../native-chat/agent-session-wire/unhandled-provider-frame' +import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from '../native-chat/agent-session-journal/journal-payload-bounds' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { CodexJournalTranslationAdmission, @@ -59,7 +65,10 @@ export class CodexJournalGenericFrames { private cancelSuppressionFlush: (() => void) | null = null constructor( - private readonly deps: Pick & { + private readonly deps: Pick< + CodexJournalTranslatorDeps, + 'sink' | 'schedule' | 'coalesceMs' | 'acquisitionId' + > & { attributionFor: CodexRowAttribution }, private readonly activeTurn: (threadId: string) => string | null @@ -71,14 +80,30 @@ export class CodexJournalGenericFrames { appendUnhandled( kind: string, payload: unknown, - threadId: string + threadId: string, + options?: UnhandledProviderFrameJournalItemOptions ): CodexJournalTranslationAdmission { - const translated = unhandledProviderFrameJournalItem('codex', kind, payload) + const translated = unhandledProviderFrameJournalItem( + 'codex', + kind, + payload, + DEFAULT_JOURNAL_PAYLOAD_LIMITS, + options + ) // A frame the classifier declines is deliberately not journaled, which is success. // Failing admission here force-closes the provider through the retry queue. - if (!translated) { - return CODEX_JOURNAL_ADMITTED + return translated ? this.appendFrameRow(threadId, payload, translated) : CODEX_JOURNAL_ADMITTED + } + + /** One frame's own row, under the identity every frame row gets, however it is worded. */ + appendFrameRow( + threadId: string, + payload: unknown, + translated: { + body: AgentJournalStatusItem + classification: UnhandledProviderFrameJournalItem['classification'] } + ): CodexJournalTranslationAdmission { const frameTurnId = readCodexTurnId(payload) ?? this.activeTurn(threadId) const turnId = frameTurnId ?? 'outside-turn' const bucket = this.bucketFor(threadId, turnId) @@ -101,7 +126,7 @@ export class CodexJournalGenericFrames { this.fallbackSequence += 1 const identity = { provider: 'orca' as const, - clientMessageId: `provider-frame:codex:${this.fallbackSequence}` + clientMessageId: `provider-frame:codex:${this.deps.acquisitionId ?? 'acquisition'}:${this.fallbackSequence}` } const attribution = this.deps.attributionFor(threadId, frameTurnId) const admission = this.deps.sink.tryAppendItem diff --git a/src/main/codex/codex-structured-journal-items.ts b/src/main/codex/codex-structured-journal-items.ts index 4e6ebe8e5e08..ec6ac67df5d4 100644 --- a/src/main/codex/codex-structured-journal-items.ts +++ b/src/main/codex/codex-structured-journal-items.ts @@ -12,6 +12,7 @@ import { type CodexThreadItem } from './codex-structured-item-translation' import { createCodexStructuredItemStreams } from './codex-structured-item-streams' +import type { CodexHelperName } from './codex-collab-agent-item-translation' import { boundStreamItem, codexStructuredItemKey } from './codex-structured-item-stream-bounds' import { codexCommandOutlivesTurn } from './codex-command-lifecycle' import type { @@ -46,7 +47,8 @@ export class CodexJournalItems { 'sink' | 'coalesceMs' | 'maxRetainedBytes' | 'schedule' > & { maxMetadataBytes?: number; attributionFor: CodexRowAttribution }, private readonly activeTurn: (threadId: string) => string | null, - private readonly suppress: (threadId: string, turnId: string) => void + private readonly suppress: (threadId: string, turnId: string) => void, + private readonly helperName?: CodexHelperName ) { this.streams = createCodexStructuredItemStreams({ sink: deps.sink, @@ -96,15 +98,17 @@ export class CodexJournalItems { ) { return { handled: true, admission: { accepted: false, reason: 'failed' } } } - const translated = codexJournalItem(item) + const itemKey = codexStructuredItemKey(event.threadId, item.id) + const started = + event.method === 'item/completed' ? this.activeItems.get(itemKey)?.item : undefined + const translated = codexJournalItem(item, this.helperName, started) const command = readCodexJournalString(item, 'command') if (command) { const boundedCommand = Buffer.from(command, 'utf8') .subarray(0, MAX_CODEX_DETAIL_BYTES) .toString('utf8') - this.details.set(codexStructuredItemKey(event.threadId, item.id), boundedCommand) + this.details.set(itemKey, boundedCommand) } - const itemKey = codexStructuredItemKey(event.threadId, item.id) if (!translated.body) { if (event.method === 'item/completed') { this.streams.forget(event.threadId, item.id) @@ -193,7 +197,8 @@ export class CodexJournalItems { threadId, turnId, identity, - item: retainedItem + item: retainedItem, + ...(this.helperName ? { helperName: this.helperName } : {}) }) } @@ -234,7 +239,7 @@ export class CodexJournalItems { } const evicted = this.activeItems.get(oldest) if (evicted) { - const translated = codexJournalItem(evicted.item).body + const translated = codexJournalItem(evicted.item, this.helperName).body if (translated) { const admission = appendCodexLifecycleItem( this.deps.sink, diff --git a/src/main/codex/codex-structured-journal-settlement.ts b/src/main/codex/codex-structured-journal-settlement.ts index a3f4c7f5599c..131de9824aae 100644 --- a/src/main/codex/codex-structured-journal-settlement.ts +++ b/src/main/codex/codex-structured-journal-settlement.ts @@ -20,6 +20,7 @@ import { type CodexTurnOrdinals } from './codex-structured-item-translation' import type { CodexStructuredItemStreams } from './codex-structured-item-streams' +import type { CodexHelperName } from './codex-collab-agent-item-translation' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' import { codexCommandOutlivesTurn } from './codex-command-lifecycle' import { @@ -34,6 +35,8 @@ export type CodexActiveJournalItem = { turnId: string | null identity: AgentJournalItemIdentity item: CodexThreadItem + /** Names the helpers a collab call acted on, so a settled revision keeps naming them. */ + helperName?: CodexHelperName } export type CodexPendingJournalPrompt = { @@ -66,7 +69,7 @@ export function settleCodexJournalSession(input: { const streamed = input.streams.snapshot(active.threadId, active.item.id) const translated = streamed ? codexStreamingJournalItem(active.item, streamed.text) - : codexJournalItem(active.item) + : codexJournalItem(active.item, active.helperName) const body = interruptedBody(translated.body) if (body) { mutations.push(settledRow(input.attributionFor, active, body)) @@ -138,7 +141,7 @@ export function settleCodexJournalTurn(input: { const streamed = input.streams.snapshot(active.threadId, active.item.id) const translated = streamed ? codexStreamingJournalItem(active.item, streamed.text) - : codexJournalItem(active.item) + : codexJournalItem(active.item, active.helperName) const body = interruptedBody(translated.body) if (body) { mutations.push(settledRow(input.attributionFor, active, body)) diff --git a/src/main/codex/codex-structured-journal-subagent-readers.test.ts b/src/main/codex/codex-structured-journal-subagent-readers.test.ts index 69e28d67ec4b..21ae85af6431 100644 --- a/src/main/codex/codex-structured-journal-subagent-readers.test.ts +++ b/src/main/codex/codex-structured-journal-subagent-readers.test.ts @@ -8,6 +8,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import type { AgentSessionTurnActivity } from '../../shared/agent-session-wire' import { selectStructuredAgentTurnActivity } from '../../shared/native-chat-turn-activity' import { latestStructuredAgentSessionAssistantMessage } from '../../shared/structured-agent-session-latest-request' import { @@ -52,7 +53,16 @@ async function session() { const root = await mkdtemp(join(tmpdir(), 'orca-codex-children-')) let journal = await openJournal(root) const deferred = createDeferredStructuredAgentSessionEventSink() - deferred.bind({ journal, fence: 1, publish: () => {} }) + const activities: (AgentSessionTurnActivity | null)[] = [] + deferred.bind({ + journal, + fence: 1, + publish: (activity) => { + if (activity !== undefined) { + activities.push(activity) + } + } + }) cleanups.push(async () => { deferred.close() await journal.close() @@ -80,6 +90,8 @@ async function session() { return { on, item, + /** Every live activity the host published to the session's clients. */ + activities, /** The parent's turn is open and has spawned a running child. */ spawnChild: () => { on(PARENT, 'turn/started', { turn: { id: PARENT_TURN } }) @@ -153,17 +165,35 @@ describe("a Codex subagent's rows on the parent's surfaces", () => { }) it("does not show the child's compaction as the parent's activity line", async () => { - const { spawnChild, item, items } = await session() + const { spawnChild, item, items, activities } = await session() spawnChild() - item(CHILD, 'item/completed', CHILD_TURN, { type: 'contextCompaction', id: 'child-compact' }) + const compaction: CodexThreadItem = { type: 'contextCompaction', id: 'child-compact' } + item(CHILD, 'item/started', CHILD_TURN, compaction) + item(CHILD, 'item/completed', CHILD_TURN, compaction) const rows = await items() - expect(selectStructuredAgentTurnActivity(rows, PARENT_TURN)).toBeNull() + expect(activities.map((activity) => activity?.text)).not.toContain( + 'Compacting the conversation' + ) + expect(selectStructuredAgentTurnActivity(rows, PARENT_TURN, activities.at(-1))).toEqual({ + kind: 'description', + text: 'Coordinating with another agent' + }) expect( rows.some((row) => row.body.kind === 'status' && row.body.text === 'Context compacted') ).toBe(true) }) + it("shows the parent's own compaction as its activity line", async () => { + const { spawnChild, item, items, activities } = await session() + spawnChild() + item(PARENT, 'item/started', PARENT_TURN, { type: 'contextCompaction', id: 'own-compact' }) + + expect( + selectStructuredAgentTurnActivity(await items(), PARENT_TURN, activities.at(-1)) + ).toEqual({ kind: 'description', text: 'Compacting the conversation' }) + }) + it("quotes the parent's own latest line, and still does after a reopen", async () => { const { spawnChild, item, reopen } = await session() spawnChild() diff --git a/src/main/codex/codex-structured-journal-translation-restore.ts b/src/main/codex/codex-structured-journal-translation-restore.ts index 5dc68d6f764d..9f7efc15ae4c 100644 --- a/src/main/codex/codex-structured-journal-translation-restore.ts +++ b/src/main/codex/codex-structured-journal-translation-restore.ts @@ -1,5 +1,12 @@ import type { AgentJournalTurnLifecycle } from '../../shared/agent-session-journal-types' -import type { CodexTurnOrdinals } from './codex-structured-item-translation' +import { readCodexThreadItem, type CodexTurnOrdinals } from './codex-structured-item-translation' +import type { CodexJournalCompactions } from './codex-structured-journal-compactions' +import type { CodexJournalItems } from './codex-structured-journal-items' +import { + readCodexNotificationThreadItem, + readCodexSubagentAnnouncements +} from './codex-subagent-activity' +import type { CodexSubagentExecutions } from './codex-subagent-executions' import { readCodexJournalRecord, readCodexJournalString @@ -12,6 +19,33 @@ import { } from './codex-structured-journal-translation-turns' import { readCodexTurnDurationMs, readCodexTurnStatus } from './codex-structured-thread-facts' +type CodexHistoryItemEvent = { threadId: string; method: string; params: unknown } + +/** One replayed item. History never runs the live item router, so a helper the replay announces + * is registered here for its name and membership only: `register` starts no execution, so no + * strip entry, record or roster row claims it runs until a live turn of its own says so. */ +export function restoreCodexHistoryItem( + event: CodexHistoryItemEvent, + input: { + primaryThreadId: string | null + compactions: Pick + items: Pick + executions: Pick + } +): CodexJournalTranslationAdmission { + const item = readCodexNotificationThreadItem(event.params, readCodexThreadItem) + const announcements = item ? readCodexSubagentAnnouncements(item, input.primaryThreadId) : [] + for (const announcement of announcements) { + input.executions.register(announcement.agentThreadId, announcement.label, undefined) + } + const compaction = input.compactions.handle(event) + if (compaction) { + return compaction + } + const translated = input.items.handle(event, 'history') + return translated.handled ? translated.admission : { accepted: false, reason: 'untranslated' } +} + /** Old providers may return the complete thread from resume. Keep that fallback * bounded before admitting any rows to the asynchronous sink. */ export const CODEX_RESTORE_MAX_OPERATIONS = 1_024 @@ -22,11 +56,7 @@ export function restoreCodexJournalThread(input: { thread: Record currentTurnIds: Map> ordinals: CodexTurnOrdinals - handleItem: (event: { - threadId: string - method: string - params: unknown - }) => CodexJournalTranslationAdmission + handleItem: (event: CodexHistoryItemEvent) => CodexJournalTranslationAdmission /** Absent when the caller has no session identity to key lifecycle rows by. */ restoreTurnLifecycle?: ( turnLifecycle: AgentJournalTurnLifecycle diff --git a/src/main/codex/codex-structured-journal-translation-streams.test.ts b/src/main/codex/codex-structured-journal-translation-streams.test.ts index ce6fd220132f..54a2c017aa05 100644 --- a/src/main/codex/codex-structured-journal-translation-streams.test.ts +++ b/src/main/codex/codex-structured-journal-translation-streams.test.ts @@ -508,37 +508,56 @@ describe('codex journal translation', () => { ]) }) - it('renders a system error carried by a suppressed status kind', () => { - const { translator, tap } = translatorWith() + it.each(['idle', 'active', 'notLoaded', 'systemError'])( + 'journals no row for a %s thread status', + (type) => { + const { translator, tap } = translatorWith() + + translator.handle( + notification('thread/status/changed', { threadId: THREAD_ID, status: { type } }) + ) + + expect(tap.rows).toEqual([]) + } + ) + + it('a failed turn journals its error, not the thread status Codex reports beside it', () => { + const tap = recorder() + const stopped: string[] = [] + const translator = createCodexJournalTranslator({ + sink: tap.sink, + primaryThreadId: () => THREAD_ID, + onPrimaryThreadStoppedRunning: () => stopped.push(THREAD_ID) + }) + translator.handle(TURN_STARTED) translator.handle( notification('thread/status/changed', { threadId: THREAD_ID, status: { type: 'systemError' } }) ) - - const timeline = projectStructuredItemsToNativeChat( - tap.rows.map((row, index) => ({ - itemId: row.key, - revision: 1, - sequence: index + 1, - observedAt: index + 1, - body: row.body - })) - ) - expect(timeline).toEqual([ - expect.objectContaining({ - role: 'system', - blocks: [ - expect.objectContaining({ - providerFrame: expect.objectContaining({ - kind: 'notification:thread/status/changed' - }) - }) - ] + translator.handle( + notification('error', { + threadId: THREAD_ID, + turnId: TURN_ID, + willRetry: false, + error: { message: 'Selected model is at capacity.' } }) + ) + translator.handle(notification('turn/completed', { turn: { id: TURN_ID, status: 'failed' } })) + + const bodies = tap.rows.map((row) => row.body) + expect(bodies.filter((body) => body.kind === 'status')).toEqual([ + expect.objectContaining({ text: 'Selected model is at capacity.', tone: 'error' }) ]) + expect(bodies.at(-1)).toMatchObject({ + kind: 'turn', + turnId: TURN_ID, + state: 'completed', + outcome: 'failure' + }) + expect(stopped).toEqual([THREAD_ID]) }) it('writes nothing more after dispose', () => { diff --git a/src/main/codex/codex-structured-journal-translation-writers.ts b/src/main/codex/codex-structured-journal-translation-writers.ts index b13a7212142d..9e577074c5e4 100644 --- a/src/main/codex/codex-structured-journal-translation-writers.ts +++ b/src/main/codex/codex-structured-journal-translation-writers.ts @@ -39,8 +39,12 @@ export function createCodexJournalTranslatorWriters(deps: CodexJournalTranslator }) const producerDeps = { ...deps, attributionFor } const genericFrames = new CodexJournalGenericFrames(producerDeps, activeTurn) - const items = new CodexJournalItems(producerDeps, activeTurn, (threadId, turnId) => - genericFrames.suppress(threadId, turnId) + const items = new CodexJournalItems( + producerDeps, + activeTurn, + (threadId, turnId) => genericFrames.suppress(threadId, turnId), + // A collab call's row names its helpers the way the roster does. + (threadId) => subagents.executions.label(threadId) ) return { activeTurns, diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index 8e05506509a3..7200e5bc76ac 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -1,3 +1,7 @@ +import { + childEndCauseOfEndedEvent, + turnVerdictForChildEnd +} from '../native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict' import { createCodexProviderActivityReader } from '../native-chat/agent-session-wire/provider-frame-activity' import { CODEX_TOKEN_USAGE_METHOD } from './codex-subagent-activity' import { @@ -7,10 +11,14 @@ import { type CodexJournalTranslatorDeps } from './codex-structured-journal-contracts' import { settleCodexJournalSession } from './codex-structured-journal-settlement' -import { restoreCodexJournalThread } from './codex-structured-journal-translation-restore' +import { + restoreCodexHistoryItem, + restoreCodexJournalThread +} from './codex-structured-journal-translation-restore' import { CodexJournalTurnBoundaries } from './codex-structured-journal-translation-turn-boundaries' import { createCodexJournalTranslatorWriters } from './codex-structured-journal-translation-writers' import { publishCodexTurnLifecycle } from './codex-structured-journal-translation-turns' +import { codexProviderRetryRowBody, isCodexProviderRetryFrame } from './codex-provider-retry-row' import { createCodexThreadItemRouter } from './codex-structured-journal-thread-item-routing' import { codexThreadStoppedRunning, readCodexTurnId } from './codex-structured-thread-facts' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' @@ -113,16 +121,13 @@ export function createCodexJournalTranslator( thread, currentTurnIds: activeTurns.byThread, ordinals: items.ordinals, - handleItem: (event) => { - const compaction = compactions.handle(event) - if (compaction) { - return compaction - } - const translated = items.handle(event, 'history') - return translated.handled - ? translated.admission - : { accepted: false, reason: 'untranslated' } - }, + handleItem: (event) => + restoreCodexHistoryItem(event, { + primaryThreadId: deps.primaryThreadId?.() ?? null, + compactions, + items, + executions: subagents.executions + }), ...(deps.sessionId !== undefined ? { restoreTurnLifecycle: (turnLifecycle) => @@ -157,14 +162,18 @@ export function createCodexJournalTranslator( currentTurnIds: activeTurns.byThread, primaryThreadId: deps.primaryThreadId?.() ?? null, ordinals: items.ordinals, - // The host saw the child go, not what Codex made of the turn, so the row - // carries no outcome: the end is observed, the verdict is unknown. + // The host saw the child go, not what Codex made of the turn: the verdict is only what + // the host's own cause says, a user's stop of this chat or else news. settledTurnLifecycle: (threadId, turnId) => turnBoundaries.ownsRecord(threadId, turnId) - ? turnBoundaries.settled(threadId, turnId, { - state: 'interrupted', - completedAt: event.observedAt ?? deps.now?.() ?? Date.now() - }) + ? turnBoundaries.settled( + threadId, + turnId, + turnVerdictForChildEnd( + childEndCauseOfEndedEvent(event), + event.observedAt ?? deps.now?.() ?? Date.now() + ) + ) : null, attributionFor }) @@ -247,6 +256,17 @@ export function createCodexJournalTranslator( return publishActivity(event, routed) } } + if (isCodexProviderRetryFrame(event)) { + // Always journaled, like any error frame: each attempt is evidence, and its publish is + // the activity the idle sweep reads. + return publishActivity( + event, + genericFrames.appendFrameRow(event.threadId, event.params, { + body: codexProviderRetryRowBody(event.params), + classification: 'error-surface' + }) + ) + } // A thread that stopped running settles no open turn: Codex clears `running` // on every error, and an open turn ends on its `turn/completed`. It releases // a send whose dispatch was never answered, which nothing else re-derives live. @@ -259,11 +279,13 @@ export function createCodexJournalTranslator( reportPrimaryThreadStoppedRunning() } // A turn-ending `error` is a row inside the turn it names; the failed - // `turn/completed` Codex sends after it is that turn's end. + // `turn/completed` Codex sends after it is that turn's end. The thread + // status was read for state above and prints nothing. const unhandled = genericFrames.appendUnhandled( `notification:${event.method}`, event.params, - event.threadId + event.threadId, + { coveredByTypedTranslator: event.method === 'thread/status/changed' } ) if (unhandled.accepted && event.method === 'error') { commands.errorShown(event.params) diff --git a/src/main/codex/codex-structured-model-catalog.ts b/src/main/codex/codex-structured-model-catalog.ts index c309fcaaaf63..e8164494dfd8 100644 --- a/src/main/codex/codex-structured-model-catalog.ts +++ b/src/main/codex/codex-structured-model-catalog.ts @@ -4,6 +4,7 @@ import type { AgentSessionOptionsResult } from '../../shared/agent-session-wire' import type { CodexAppServerConnection } from './codex-app-server-connection' +import { structuredAgentSessionOptionModels } from '../native-chat/agent-session-wire/structured-agent-session-option-models' import { codexFastModeSupport, readCodexFastModeTier } from './codex-structured-fast-mode' import { applyCodexConfiguredLaunchDefaults, @@ -141,15 +142,11 @@ export function composeCodexSessionOptionCatalog( reportedServiceTierKnown?: boolean } ): CodexSessionOptionCatalog { - const models = listing.models.map((entry) => ({ ...entry })) - if (input.current.model && !models.some((model) => model.id === input.current.model)) { - models.push({ - id: input.current.model, - label: input.current.model, - isDefault: false, - efforts: [] - }) - } + const models = structuredAgentSessionOptionModels( + listing.models.map((entry) => ({ ...entry })), + input.current.model, + (row) => row + ) const model = input.current.model ?? models.find((entry) => entry.isDefault)?.id ?? models[0]?.id if (!model) { throw new Error('codex app-server returned no available models') diff --git a/src/main/codex/codex-structured-prompt-ownership.ts b/src/main/codex/codex-structured-prompt-ownership.ts index 02633874d03b..effab9662bf5 100644 --- a/src/main/codex/codex-structured-prompt-ownership.ts +++ b/src/main/codex/codex-structured-prompt-ownership.ts @@ -12,6 +12,7 @@ import { } from './codex-structured-prompt-replies' import { requireLiveCodexSession, type CodexSession } from './codex-structured-session-state' import { interruptCodexTurn } from './codex-structured-turn-cancellation' +import { codexRunningOrOpeningTurn } from './codex-structured-turn-open-wait' type CancelInput = Parameters[0] type AnswerInput = Parameters[0] @@ -21,11 +22,6 @@ function providerTurnId(session: CodexSession, turnId: string): string | undefin return session.translator ? session.translator.commandProviderTurnId(turnId) : turnId } -/** How long a Stop waits for Codex to open the turn it answered a send into. A close or quit queued - * behind the Stop spends this out of the eviction budget, so a full wait plus a slow provider - * close can overrun it; the next launch's recovery then settles the lease. */ -export const CODEX_STOP_TURN_OPEN_WAIT_MS = 5_000 - /** * A Stop that names no turn: interrupt the turn the journal shows, or else the one Codex reported * started and not yet ended, which the journal can trail by a publish, or else the one Codex @@ -40,7 +36,7 @@ async function cancelCodexConversation( // A turn the journal shows that Codex has not started yet (a compaction's) has nothing to stop. const turnId = liveTurnId === null - ? ([...(session.activeTurnIds ?? [])].at(-1) ?? (await openedAnsweredTurn(session))) + ? await codexRunningOrOpeningTurn(session) : providerTurnId(session, liveTurnId) // The wait for the turn to open can outlive the session it began on. if ( @@ -54,19 +50,6 @@ async function cancelCodexConversation( return interruptCodexTurn({ session, threadId: session.threadId, turnId, requestTimeoutMs }) } -/** The turn Codex answered a send into and has not opened yet, once it opens; null when it ends, - * the thread stops running or the child exits first, or the wait runs out. */ -async function openedAnsweredTurn(session: CodexSession): Promise { - const openTurnIds = session.activeTurnIds ?? new Set() - const answered = session.dispatchEchoes.answeredUnopenedTurn(session.threadId, openTurnIds) - if (!answered) { - return null - } - // Codex refuses an interrupt before it opens the turn. - await session.turnOpenWaits.wait(answered, CODEX_STOP_TURN_OPEN_WAIT_MS) - return session.activeTurnIds?.has(answered) ? answered : null -} - export async function cancelCodexStructuredTurn(input: { request: CancelInput sessions: Map diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 73c1653aaddb..37c34a4bf3a1 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -77,10 +77,13 @@ export async function acquireCodexStructuredSession(input: { : null const subagentExecutions = new CodexSubagentExecutions() const dispatchEchoes = createCodexDispatchEchoes() + // Minted before the translator, which names this connection's frame rows with it. + const acquisitionGeneration = mintCodexAcquisitionGeneration(deps) const translator = acquireInput.events ? createCodexJournalTranslator({ sink: acquireInput.events, sessionId, + acquisitionId: acquisitionGeneration, ...(deps.now ? { now: deps.now } : {}), primaryThreadId: () => primaryThreadId, onPrimaryThreadStoppedRunning: () => deps.onPrimaryThreadStoppedRunning?.({ sessionId }), @@ -168,7 +171,6 @@ export async function acquireCodexStructuredSession(input: { connection: acquisition.connection, error, prompts: acquisition.prompts, - onBackgroundTasksChanged: deps.onBackgroundTasksChanged, ...(deps.onEvent ? { onEvent: deps.onEvent } : {}) }) } finally { @@ -210,7 +212,7 @@ export async function acquireCodexStructuredSession(input: { linkId: deps.mintLinkId?.(), observedAt: deps.now?.() ?? Date.now() }), - acquisitionGeneration: mintCodexAcquisitionGeneration(deps) + acquisitionGeneration } assertCodexConnectionOpen(connection, sessionId) acquisitions.assertCurrent(sessionId, attempt) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index e0b1a93b395a..9d2202076fad 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -3,13 +3,15 @@ import type { AgentJournalMessageItem, AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import { isCodexAppServerRequestError } from './codex-app-server-connection' import type { AgentSessionAcquisition, AgentSessionDispatchOutcome, StructuredAgentSessionAcquireInput, StructuredAgentSessionAdapter, - StructuredAgentSessionSetOptionInput + StructuredAgentSessionSetOptionInput, + StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexJournalTranslationAdmission } from './codex-structured-journal-translation' import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start' @@ -75,9 +77,6 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap sessions: this.sessions, acquisitions: this.acquisitions, ...(deps.onEvent ? { onEvent: deps.onEvent } : {}), - ...(deps.onBackgroundTasksChanged - ? { onBackgroundTasksChanged: deps.onBackgroundTasksChanged } - : {}), forgetNotificationRetries: (sessionId) => this.notificationRetries.clear(sessionId, null) }) } @@ -137,11 +136,9 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap settleCodexSendsInEndedTurn(session, event.method, event.params, (settlement) => this.deps.onDispatchSettledLate?.({ sessionId: event.sessionId, ...settlement }) ) - // After the admission check, so a refused frame is observed by the strip + // After the admission check, so a refused frame is observed by the child records // only on the retry that also reaches the journal. - if (session.backgroundTasks.observe(event, session.prompts.takeAbandonedCommands())) { - this.deps.onBackgroundTasksChanged?.(event.sessionId, session.backgroundTasks.state) - } + session.backgroundTasks.observe(event, session.prompts.takeAbandonedCommands()) // After the journal and the parent's republished row, never ahead of either. session.backgroundTasks.publishChildWork() } @@ -168,9 +165,16 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap ) } - backgroundTaskState: NonNullable = ( + /** The tracker's own roster. No host decision reads it: the host's child records are the one + * owner of "what runs", and this stays only so tests can hold the two rule sets side by side. */ + backgroundTaskState = (sessionId: string): AgentSessionBackgroundTaskState | null | undefined => + this.sessions.get(sessionId)?.backgroundTasks.state + + // Codex exposes no honest stop for a child thread or a persistent command. + backgroundTaskStops: NonNullable = ( sessionId - ) => this.sessions.get(sessionId)?.backgroundTasks.state + ) => + this.sessions.has(sessionId) ? { supportsTaskStop: false, supportsStopAll: false } : undefined bindPromptItemId = ( sessionId: string, @@ -285,9 +289,11 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap identity: AgentSessionJournalIdentity }): Promise => this.sessions.get(input.identity.sessionId)?.historyPath ?? null - closeSession = (sessionId: string): Promise => this.teardown.close(sessionId) + closeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => + this.teardown.close(sessionId, cause) forceCloseSession = (sessionId: string): Promise => this.teardown.forceClose(sessionId) - disposeSession = (sessionId: string): Promise => this.teardown.close(sessionId) + disposeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => + this.teardown.close(sessionId, cause) closeAll = (): Promise => this.teardown.closeAll() releaseAcquisition = (input: { sessionId: string }): Promise => this.teardown.close(input.sessionId) diff --git a/src/main/codex/codex-structured-session-background-tasks.test.ts b/src/main/codex/codex-structured-session-background-tasks.test.ts index 97b6f4e47a53..4e2515da7d43 100644 --- a/src/main/codex/codex-structured-session-background-tasks.test.ts +++ b/src/main/codex/codex-structured-session-background-tasks.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' -import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import type { CodexAppServerConnection, CodexAppServerConnectionHandlers, @@ -11,9 +11,11 @@ import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexStructuredSessionEvent } from './codex-structured-session-state' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -// Proves the strip is actually REACHED from provider traffic: the tracker is -// unit-tested separately, and a producer that is correct but unwired publishes -// nothing while every one of its own tests stays green. +// Proves the host's child records are actually REACHED from provider traffic: the tracker is +// unit-tested separately, and a producer that is correct but unwired publishes nothing while +// every one of its own tests stays green. Every surface, the strip included, reads those records. + +type Published = { sessionId: string; evidence: AgentChildWorkEvidence['type'][] } const THREAD_ID = '01a07d54-3785-71d0-b065-82c8ebbc572a' const PARENT_TURN = '01a07d54-37be-72e1-8206-8f0c23dd2cef' @@ -76,7 +78,7 @@ const TURN_COMPLETED = { } async function adapterWithSession( - published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[], + published: Published[], events?: StructuredAgentSessionEventSink, onEvent?: (event: CodexStructuredSessionEvent) => void, close?: () => Promise @@ -93,7 +95,8 @@ async function adapterWithSession( openConnection: codex.openConnection, readProcessStartTime: async () => 1_700_000_000_000, onEvent, - onBackgroundTasksChanged: (sessionId, state) => published.push({ sessionId, state }) + onChildWorkEvidence: (sessionId, evidence) => + published.push({ sessionId, evidence: evidence.map((edge) => edge.type) }) }) await adapter.acquire({ identity: identity('session-1'), @@ -112,9 +115,9 @@ async function adapterWithSession( return { adapter, codex } } -describe('codex background tasks reach the strip', () => { +describe("codex background tasks reach the host's child records", () => { it('clears natural-exit state before lifecycle observers can read it', async () => { - const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = [] + const published: Published[] = [] const onEvent = vi.fn() const { adapter, codex } = await adapterWithSession(published, undefined, onEvent) const spawn = subagentNotification('started') @@ -129,12 +132,12 @@ describe('codex background tasks reach the strip', () => { }) codex.handlers().onExit?.(new Error('provider exited')) expect(adapter.backgroundTaskState('session-1')).toBeNull() - expect(published).toEqual([{ sessionId: 'session-1', state: null }]) + expect(published).toEqual([{ sessionId: 'session-1', evidence: ['session-ended'] }]) await adapter.closeSession('session-1') }) it('keeps live tasks when close is refused', async () => { - const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = [] + const published: Published[] = [] const close = vi.fn(async () => false) const { adapter, codex } = await adapterWithSession(published, undefined, undefined, close) const spawn = subagentNotification('started') @@ -150,7 +153,7 @@ describe('codex background tasks reach the strip', () => { }) it('does not let an old exit callback clear a replacement roster', async () => { - const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = [] + const published: Published[] = [] const { adapter, codex } = await adapterWithSession(published) const oldExit = codex.handlers().onExit await adapter.acquire({ identity: identity('session-1'), fence: 8, spawnToken: 'spawn-10' }) @@ -170,7 +173,7 @@ describe('codex background tasks reach the strip', () => { }) it('recovers the exact provider generation when command metadata cannot be admitted', async () => { - const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = [] + const published: Published[] = [] const observed: CodexStructuredSessionEvent[] = [] const appendItem = vi.fn() const { adapter, codex } = await adapterWithSession( @@ -209,7 +212,7 @@ describe('codex background tasks reach the strip', () => { reason: 'notification admission failed (failed)' }) ]) - expect(published).toEqual([{ sessionId: 'session-1', state: null }]) + expect(published).toEqual([{ sessionId: 'session-1', evidence: ['session-ended'] }]) } finally { admission.mockRestore() await adapter.closeSession('session-1') @@ -217,7 +220,7 @@ describe('codex background tasks reach the strip', () => { }) it('publishes the fan-out while it runs and keeps it past the spawning turn', async () => { - const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = [] + const published: Published[] = [] const { adapter, codex } = await adapterWithSession(published) const running = { state: 'monitoring', @@ -227,20 +230,20 @@ describe('codex background tasks reach the strip', () => { const spawn = subagentNotification('started') codex.handlers().onNotification?.(spawn.method, spawn.params) - // Mid-turn: the child is running, so the strip reports it now. - expect(published).toEqual([{ sessionId: 'session-1', state: running }]) + // Mid-turn: the child is running, so its record is live now. + expect(published).toEqual([{ sessionId: 'session-1', evidence: ['live'] }]) expect(adapter.backgroundTaskState('session-1')).toEqual(running) published.length = 0 codex.handlers().onNotification?.(TURN_COMPLETED.method, TURN_COMPLETED.params) - // Turn end is not the child's outcome: no republish and no settle. + // Turn end is not the child's outcome: nothing reaches its record. expect(published).toEqual([]) expect(adapter.backgroundTaskState('session-1')).toEqual(running) }) - it('clears the strip when the session closes', async () => { - const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = [] + it("hands the session's end to its child records when the session closes", async () => { + const published: Published[] = [] const { adapter, codex } = await adapterWithSession(published) const spawn = subagentNotification('started') codex.handlers().onNotification?.(spawn.method, spawn.params) @@ -249,9 +252,8 @@ describe('codex background tasks reach the strip', () => { expect(await adapter.closeSession('session-1')).toBe(true) - // Explicit null, not silence: the reader answers `undefined` once the - // session is gone, which every channel treats as "unchanged". - expect(published).toEqual([{ sessionId: 'session-1', state: null }]) + // The records hear the session end, which settles the child still running. + expect(published).toEqual([{ sessionId: 'session-1', evidence: ['session-ended'] }]) expect(adapter.backgroundTaskState('session-1')).toBeUndefined() }) }) diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index 5bf973308b10..198f2114a655 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -5,10 +5,12 @@ import { cancelCodexAcquisitionAttempt, type CodexAcquisitionRegistry, type CodexSession, - type CodexStructuredSessionAdapterDeps, type CodexStructuredSessionEvent } from './codex-structured-session-state' -import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { + StructuredAgentSessionEndedEvent, + StructuredAgentSessionStopCause +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' export function handleCodexSessionExit(input: { sessions: Map @@ -21,7 +23,6 @@ export function handleCodexSessionExit(input: { prompts?: CodexSession['prompts'] allowFailedSettlement?: boolean onEvent?: (event: CodexStructuredSessionEvent) => void - onBackgroundTasksChanged?: CodexStructuredSessionAdapterDeps['onBackgroundTasksChanged'] }): boolean { const session = input.sessions.get(input.sessionId) if (!session || session.connection !== input.connection || session.ended) { @@ -40,6 +41,9 @@ export function handleCodexSessionExit(input: { ? agentSessionFailureFact('hostFault') : agentSessionFailureFact('providerExited', { detail: providerDiagnosticOf(input.error) }), cause: session.requestedClose ? 'requested-close' : 'unexpected-exit', + ...(session.requestedClose && session.closeStopCause + ? { stopCause: session.closeStopCause } + : {}), fence: session.fence, acquisitionGeneration: session.acquisitionGeneration, observedAt: session.exitObservedAt @@ -57,7 +61,6 @@ export function handleCodexSessionExit(input: { // recovery is what settles the sends these were armed for. session.dispatchEchoes.clear() session.backgroundTasks.clear() - input.onBackgroundTasksChanged?.(input.sessionId, null) // Every close path funnels here, so the session's children end with it on each one. session.backgroundTasks.publishChildWork() session.unbindReadingControl?.() @@ -74,6 +77,8 @@ export async function closeCodexPublishedSession( options?: { allowFailedSettlement?: boolean requestedClose?: boolean + /** Who asked for a requested close; the translator settles the open turn with it. */ + stopCause?: StructuredAgentSessionStopCause expectedFence?: number expectedAcquisitionGeneration?: string unexpectedReason?: Error @@ -93,6 +98,7 @@ export async function closeCodexPublishedSession( // Sink-failure recovery force-closes the child but must preserve the // observed-exit cause so host lease settlement runs as an unexpected death. session.requestedClose = options?.requestedClose ?? true + session.closeStopCause = options?.stopCause // Keep the session indexed until the child exit is observed. A timeout or // failed kill must leave the live connection available for a safe retry. const exited = await session.connection.close() @@ -124,7 +130,8 @@ export async function closeCodexSession( sessionId: string, sessions: Map, acquisitions: CodexAcquisitionRegistry, - onEvent?: (event: CodexStructuredSessionEvent) => void + onEvent?: (event: CodexStructuredSessionEvent) => void, + stopCause?: StructuredAgentSessionStopCause ): Promise { const attempt = acquisitions.get(sessionId) if (!(await cancelCodexAcquisitionAttempt(attempt))) { @@ -133,7 +140,7 @@ export async function closeCodexSession( if (attempt) { acquisitions.deleteIfCurrent(sessionId, attempt) } - return closeCodexPublishedSession(sessions, sessionId, onEvent) + return closeCodexPublishedSession(sessions, sessionId, onEvent, stopCause ? { stopCause } : {}) } export async function closeAllCodexSessions( diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index fcfb120fe821..896d2b352af2 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -15,11 +15,13 @@ import { type CodexTurnOpenWaits } from './codex-structured-turn-open-wait' import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' -import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import type { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexJournalTranslator } from './codex-structured-journal-translation' -import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { + StructuredAgentSessionEndedEvent, + StructuredAgentSessionStopCause +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy' import type { AgentModelCatalogSessionAccess, @@ -78,10 +80,6 @@ export type CodexStructuredSessionAdapterDeps = { /** Host capability seam; production uses the native Windows process table. */ isWindowsProcessStartTimeAvailable?: () => boolean onEvent?: (event: CodexStructuredSessionEvent) => void - onBackgroundTasksChanged?: ( - sessionId: string, - state: AgentSessionBackgroundTaskState | null - ) => void /** What the session's child work did, delivered after the journal handled the frame. */ onChildWorkEvidence?: (sessionId: string, evidence: AgentChildWorkEvidence[]) => void /** A send admitted earlier: its identity once Codex echoes it, or its rejection when the turn @@ -111,6 +109,8 @@ export type CodexSession = { /** First observed child exit survives rejected settlement admission. */ exitObservedAt?: number requestedClose: boolean + /** Who asked for the requested close in flight, carried onto its `ended`. */ + closeStopCause?: StructuredAgentSessionStopCause fence: number acquisitionGeneration: string threadId: string diff --git a/src/main/codex/codex-structured-session-teardown.ts b/src/main/codex/codex-structured-session-teardown.ts index dcd38eb82fe2..05eb1e4b15e6 100644 --- a/src/main/codex/codex-structured-session-teardown.ts +++ b/src/main/codex/codex-structured-session-teardown.ts @@ -4,7 +4,7 @@ // session owns are cleared exactly once, and only when the child was actually // proven stopped — a refused close leaves the session indexed for a retry. -import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' +import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { closeAllCodexSessions, closeCodexPublishedSession, @@ -20,22 +20,19 @@ export type CodexStructuredSessionTeardownDeps = { sessions: Map acquisitions: CodexAcquisitionRegistry onEvent?: (event: CodexStructuredSessionEvent) => void - onBackgroundTasksChanged?: ( - sessionId: string, - state: AgentSessionBackgroundTaskState | null - ) => void forgetNotificationRetries: (sessionId: string) => void } export class CodexStructuredSessionTeardown { constructor(private readonly deps: CodexStructuredSessionTeardownDeps) {} - close = async (sessionId: string): Promise => { + close = async (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => { const closed = await closeCodexSession( sessionId, this.deps.sessions, this.deps.acquisitions, - this.deps.onEvent + this.deps.onEvent, + cause ) return this.settled(sessionId, closed) } @@ -84,10 +81,6 @@ export class CodexStructuredSessionTeardown { private settled(sessionId: string, closed: boolean): boolean { if (closed) { this.deps.forgetNotificationRetries(sessionId) - // Explicit null, not silence: the state reader answers `undefined` once - // the session leaves the map, which every channel reads as "unchanged" - // and would leave the last roster on screen. - this.deps.onBackgroundTasksChanged?.(sessionId, null) } return closed } diff --git a/src/main/codex/codex-structured-thread-facts.ts b/src/main/codex/codex-structured-thread-facts.ts index f2a42e6d3dbf..f9f2b78b7f9b 100644 --- a/src/main/codex/codex-structured-thread-facts.ts +++ b/src/main/codex/codex-structured-thread-facts.ts @@ -62,6 +62,34 @@ export function readCodexTurnDurationMs(payload: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null } +/** `error` carries `willRetry`: Codex sets it on a stream error it is about to + * retry, and omits it (false) on one that ended the turn the frame names. */ +export function readCodexErrorWillRetry(payload: unknown): boolean { + return record(payload)?.willRetry === true +} + +/** `error.message` on an `error` frame: Codex's own words for the person reading the chat. */ +export function readCodexErrorMessage(payload: unknown): string | null { + return nonEmptyString(record(record(payload)?.error)?.message) +} + +/** `error.additionalDetails`: what failed underneath, which Codex shows under its message. */ +export function readCodexErrorAdditionalDetails(payload: unknown): string | null { + return nonEmptyString(record(record(payload)?.error)?.additionalDetails) +} + +/** `error.codexErrorInfo` is a bare variant (`"serverOverloaded"`) or one keyed to its fields + * (`{"responseStreamDisconnected":{"httpStatusCode":502}}`); read as the variant and its status. */ +export function readCodexErrorInfo(payload: unknown): { error: string; status?: unknown } | null { + const info = record(record(payload)?.error)?.codexErrorInfo + const bare = nonEmptyString(info) + if (bare) { + return { error: bare } + } + const [variant, fields] = Object.entries(record(info) ?? {})[0] ?? [] + return variant ? { error: variant, status: record(fields)?.httpStatusCode } : null +} + /** `thread/status/changed` carries a TAGGED status (`{status:{type}}`), never a * bare string. `idle` and `systemError` are the two arms that mean the thread * is not running; `active` and `notLoaded` are not. */ diff --git a/src/main/codex/codex-structured-turn-open-wait.test.ts b/src/main/codex/codex-structured-turn-open-wait.test.ts index 125be2bba0b0..c86a39fb59ef 100644 --- a/src/main/codex/codex-structured-turn-open-wait.test.ts +++ b/src/main/codex/codex-structured-turn-open-wait.test.ts @@ -9,7 +9,7 @@ import { codexTurnLifecycleRig, settledWithin } from './codex-structured-dispatch-test-support' -import { CODEX_STOP_TURN_OPEN_WAIT_MS } from './codex-structured-prompt-ownership' +import { CODEX_TURN_OPEN_WAIT_MS } from './codex-structured-turn-open-wait' type Rig = Awaited> @@ -137,7 +137,7 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi outcome = value }) - await vi.advanceTimersByTimeAsync(CODEX_STOP_TURN_OPEN_WAIT_MS - 1) + await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS - 1) expect(outcome).toBe('held') await vi.advanceTimersByTimeAsync(1) diff --git a/src/main/codex/codex-structured-turn-open-wait.ts b/src/main/codex/codex-structured-turn-open-wait.ts index 1e303a58fba8..9f30b292ff0c 100644 --- a/src/main/codex/codex-structured-turn-open-wait.ts +++ b/src/main/codex/codex-structured-turn-open-wait.ts @@ -1,12 +1,18 @@ -// A Stop's wait for the turn Codex answered a send into to open, or provably not to: it ended, -// the thread stopped running, or the child is gone. Held in memory only. +// A Stop's or a send's wait for the turn Codex answered a send into to open, or provably not +// to: it ended, the thread stopped running, or the child is gone. Held in memory only. +import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' import { codexThreadStoppedRunning, readCodexThreadId, readCodexTurnId } from './codex-structured-thread-facts' +/** How long a Stop or a send waits for Codex to open the turn it answered a send into. A close or + * quit queued behind either spends this out of the eviction budget, so a full wait plus a slow + * provider close can overrun it; the next launch's recovery then settles the lease. */ +export const CODEX_TURN_OPEN_WAIT_MS = 5_000 + export type CodexTurnOpenWaits = { /** Resolves once `turnId` opens or can no longer, and after `withinMs` at the latest. */ wait: (turnId: string, withinMs: number) => Promise @@ -54,3 +60,37 @@ export function createCodexTurnOpenWaits(): CodexTurnOpenWaits { releaseAll: () => release() } } + +/** + * The turn a Stop or a send names: the latest Codex reported started and not ended, or else the + * one Codex answered a send into, once it opens. Null when none is running and that one ends, the + * thread stops running or the child exits first, or the wait runs out; each such turn is waited + * for once. + */ +export async function codexRunningOrOpeningTurn(session: { + threadId: string + activeTurnIds?: ReadonlySet + dispatchEchoes: Pick + turnOpenWaits: Pick +}): Promise { + const running = [...(session.activeTurnIds ?? [])].at(-1) + if (running) { + return running + } + const answered = session.dispatchEchoes.answeredUnopenedTurn( + session.threadId, + session.activeTurnIds ?? new Set() + ) + if (!answered) { + return null + } + // Codex refuses an interrupt, and before 0.148 a steer, until it opens the turn. + await session.turnOpenWaits.wait(answered, CODEX_TURN_OPEN_WAIT_MS) + if (session.activeTurnIds?.has(answered)) { + return answered + } + // Before 0.148 a turn that fails before it starts reports no end; one that opens later is still + // found running. + session.dispatchEchoes.leftUnopened(session.threadId, answered) + return null +} diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 825a6c3c3752..7f0a2ec0c239 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -9,6 +9,10 @@ import { import { isCodexAppServerUnsupportedError } from './codex-app-server-session' import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' import { readCodexTurnId } from './codex-structured-thread-facts' +import { + codexRunningOrOpeningTurn, + type CodexTurnOpenWaits +} from './codex-structured-turn-open-wait' import { codexDispatchRejection, codexTurnEndRejection @@ -16,12 +20,14 @@ import { import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' // Writing a Codex turn and learning which message landed where, which are not -// the same event. `turn/start` answers as soon as Codex owns the message, but a -// message issued while a turn is running is COALESCED into that turn: the same -// turn id comes back, no second `turn/started` fires, and the user message is -// echoed only when the running turn reaches it. So the response proves -// admission and nothing about identity, which the echo settles later. The turn -// it names is kept with the send, so that turn's end can settle it. +// the same event. The answer proves admission and nothing about identity, which +// the echo settles later; the turn it names is kept with the send, so that +// turn's end can settle it. A message sent while a turn is running goes in as +// `turn/steer` naming that turn, so the answer names the turn that carries it. +// `turn/start` would also steer it, with no second `turn/started`, but a Codex +// before 0.148 answers that with an id no turn ever opens or ends under. So a send +// made after Codex answered an earlier one, before it opened that turn, waits for +// the turn to open and steers it. /** Keys Codex accepts as per-turn overrides. An unlisted key would otherwise * become an arbitrary client-controlled `turn/start` parameter. Permission posture is owned by @@ -47,6 +53,8 @@ export type CodexTurnHost = { reportedOptions?: { model?: string } fastModeTierByModel: ReadonlyMap dispatchEchoes: CodexDispatchEchoes + activeTurnIds?: ReadonlySet + turnOpenWaits: Pick } function turnInputFor(body: AgentJournalMessageItem): Record[] { @@ -90,6 +98,36 @@ function codexTurnOptions(host: CodexTurnHost): Record { return { ...options, serviceTier: tierId } } +/** + * Steers a send into the turn Codex last reported running. Null when Codex refused the steer, + * which it does before taking any input: that turn ended or changed, it cannot be steered, or + * this Codex has no `turn/steer`. Per-turn options ride on the next `turn/start`. + */ +async function steerCodexTurn( + host: CodexTurnHost, + expectedTurnId: string, + input: { clientMessageId: string; body: AgentJournalMessageItem; timeoutMs?: number } +): Promise<{ turnId: string } | null> { + try { + const answer = await host.connection.request( + 'turn/steer', + { + threadId: host.threadId, + expectedTurnId, + clientUserMessageId: input.clientMessageId, + input: turnInputFor(input.body) + }, + { timeoutMs: input.timeoutMs } + ) + return { turnId: readCodexTurnId(answer) ?? expectedTurnId } + } catch (error) { + if (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) { + return null + } + throw error + } +} + /** * Hands one submission to Codex. False means the bounded correlation window * refused it before the write; otherwise resolves with the turn Codex answered @@ -109,6 +147,16 @@ export async function startCodexTurn( if (!host.dispatchEchoes.arm(input.clientMessageId, input.requestedAt)) { return false } + const runningTurnId = await codexRunningOrOpeningTurn(host) + let steered = runningTurnId ? await steerCodexTurn(host, runningTurnId, input) : null + // Refused because a turn Orca heard of meanwhile is running: steer that one, once. + const runningSince = steered ? undefined : [...(host.activeTurnIds ?? [])].at(-1) + if (runningSince && runningSince !== runningTurnId) { + steered = await steerCodexTurn(host, runningSince, input) + } + if (steered) { + return steered + } const answer = await host.connection.request( 'turn/start', { @@ -126,7 +174,7 @@ export async function startCodexTurn( * One submission's outcome as the wire must read it: admitted means Codex owns * the message and its identity settles on the echo, rejected is Codex answering * and declining. Elapsed time is never evidence here, because the wait a - * coalesced send would face is bounded only by the running turn. + * steered send would face is bounded only by the running turn. */ export async function dispatchCodexTurn( session: CodexTurnHost, diff --git a/src/main/codex/codex-structured-turn-steer.test.ts b/src/main/codex/codex-structured-turn-steer.test.ts new file mode 100644 index 000000000000..c1b982709b30 --- /dev/null +++ b/src/main/codex/codex-structured-turn-steer.test.ts @@ -0,0 +1,266 @@ +// A send made while a Codex turn runs goes in as `turn/steer` naming that turn, so the +// send is bound to the turn that carries it and that turn's end can settle it. A steer +// Codex refuses took no input, so the send steers a turn opened meanwhile, once, or else +// falls back to `turn/start`. A send made before Codex opens an earlier send's turn waits for it. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CodexAppServerRequestError } from './codex-app-server-request-error' +import { + CodexAppServerTimeoutError, + CodexAppServerUnsupportedError +} from './codex-app-server-session' +import { + acquiredCodexAdapter, + codexTurnLifecycleRig, + echoUserMessage, + fakeCodexAppServer, + settledWithin, + startTurn, + CODEX_TEST_THREAD_ID, + CODEX_TEST_USER_MESSAGE, + type CodexTestRoute, + type LateSettlement +} from './codex-structured-dispatch-test-support' +import { CODEX_TURN_OPEN_WAIT_MS } from './codex-structured-turn-open-wait' + +async function rig(routes: Record) { + const codex = fakeCodexAppServer(routes) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + const send = (clientMessageId: string) => + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId, + body: CODEX_TEST_USER_MESSAGE, + fence: 7 + }) + const methods = () => connection.calls.map(({ method }) => method) + const endTurn = (turnId: string, status: 'completed' | 'interrupted') => + connection.handlers.onNotification?.('turn/completed', { + threadId: CODEX_TEST_THREAD_ID, + turn: { id: turnId, status } + }) + return { connection, settlements, send, methods, endTurn } +} + +const refusedSteer = (message: string): CodexAppServerRequestError => + new CodexAppServerRequestError( + 'turn/steer', + -32600, + `codex app-server turn/steer failed: ${message}`, + message + ) + +describe('a Codex send while a turn runs', () => { + it('steers into that turn by name, and that turn ending unechoed withdraws it', async () => { + const { connection, settlements, send, methods, endTurn } = await rig({ + 'turn/steer': () => ({ turnId: 'turn-1' }), + // A Codex before 0.148 answers a steered start with a turn that never opens. + 'turn/start': () => ({ turn: { id: 'submission-7' } }) + }) + startTurn(connection, 'turn-1') + + expect(await send('client-1')).toEqual({ state: 'admitted' }) + expect(methods()).toEqual(['thread/start', 'turn/steer']) + expect(connection.calls.at(-1)?.params).toEqual({ + threadId: CODEX_TEST_THREAD_ID, + expectedTurnId: 'turn-1', + clientUserMessageId: 'client-1', + input: [{ type: 'text', text: 'ship it' }] + }) + + endTurn('turn-1', 'interrupted') + + expect(settlements).toEqual([ + expect.objectContaining({ + sessionId: 'session-1', + clientMessageId: 'client-1', + state: 'rejected', + rejection: { kind: 'cancelled' } + }) + ]) + }) + + it('settles on its echo when the turn completes with it', async () => { + const { connection, settlements, send, endTurn } = await rig({ + 'turn/steer': () => ({ turnId: 'turn-1' }) + }) + startTurn(connection, 'turn-1') + + expect(await send('client-1')).toEqual({ state: 'admitted' }) + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' }) + endTurn('turn-1', 'completed') + + expect(settlements).toEqual([ + expect.objectContaining({ + clientMessageId: 'client-1', + providerIdentity: expect.objectContaining({ turnId: 'turn-1' }) + }) + ]) + }) + + it.each([ + ['the turn ended first', () => refusedSteer('no active turn to steer')], + [ + 'another turn is running', + () => refusedSteer('expected active turn id `turn-1` but found `turn-2`') + ], + ['this Codex has no turn/steer', () => new CodexAppServerUnsupportedError('method not found')] + ])('falls back to turn/start when Codex refuses the steer because %s', async (_case, error) => { + const { connection, send, methods } = await rig({ + 'turn/steer': () => { + throw error() + }, + 'turn/start': () => ({ turn: { id: 'turn-2' } }) + }) + startTurn(connection, 'turn-1') + + expect(await send('client-1')).toEqual({ state: 'admitted' }) + expect(methods()).toEqual(['thread/start', 'turn/steer', 'turn/start']) + expect(connection.calls.at(-1)?.params).toMatchObject({ clientUserMessageId: 'client-1' }) + }) + + it('steers once more when Codex refuses because a turn Orca just heard of is running', async () => { + let steers = 0 + const { connection, settlements, send, methods, endTurn } = await rig({ + 'turn/steer': () => { + steers += 1 + if (steers === 1) { + // Codex moved on to a turn Orca did not start; its frames land before the refusal is read. + endTurn('turn-1', 'completed') + startTurn(connection, 'turn-2') + throw refusedSteer('expected active turn id `turn-1` but found `turn-2`') + } + return { turnId: 'turn-2' } + } + }) + startTurn(connection, 'turn-1') + + expect(await send('client-1')).toEqual({ state: 'admitted' }) + expect(methods()).toEqual(['thread/start', 'turn/steer', 'turn/steer']) + expect(connection.calls.at(-1)?.params).toMatchObject({ expectedTurnId: 'turn-2' }) + endTurn('turn-2', 'interrupted') + + expect(settlements).toEqual([ + expect.objectContaining({ clientMessageId: 'client-1', state: 'rejected' }) + ]) + }) + + it('is rejected in Codex words and disarmed when Codex refuses the steer and the start', async () => { + const { connection, settlements, send, methods } = await rig({ + 'turn/steer': () => { + throw refusedSteer('active turn cannot be steered') + }, + 'turn/start': () => { + throw new CodexAppServerRequestError( + 'turn/start', + -32600, + 'codex app-server turn/start failed: thread not found', + 'thread not found' + ) + } + }) + startTurn(connection, 'turn-1') + + expect(await send('client-1')).toEqual({ + state: 'rejected', + reason: 'The provider did not accept this message: thread not found.', + rejection: { + kind: 'providerRejected', + detail: { text: 'thread not found', audience: 'person' } + } + }) + expect(methods()).toEqual(['thread/start', 'turn/steer', 'turn/start']) + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' }) + expect(settlements).toEqual([]) + }) + + it('never re-sends a steer that may have landed, and keeps it armed for its echo', async () => { + const { connection, settlements, send, methods } = await rig({ + 'turn/steer': () => { + throw new CodexAppServerTimeoutError('codex app-server turn/steer exceeded 100ms') + } + }) + startTurn(connection, 'turn-1') + + await expect(send('client-1')).rejects.toThrow('turn/steer exceeded') + expect(methods()).toEqual(['thread/start', 'turn/steer']) + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' }) + + expect(settlements).toEqual([expect.objectContaining({ clientMessageId: 'client-1' })]) + }) + + it('starts a turn with no steer when none is running', async () => { + const { send, methods, endTurn, connection } = await rig({ + 'turn/start': () => ({ turn: { id: 'turn-1' } }) + }) + startTurn(connection, 'turn-0') + endTurn('turn-0', 'completed') + + expect(await send('client-1')).toEqual({ state: 'admitted' }) + expect(methods()).toEqual(['thread/start', 'turn/start']) + }) +}) + +describe('a Codex send made after Codex answered an earlier one, before it opened that turn', () => { + /** A Codex before 0.148, which names a steered start falsely and steers only an opened turn. */ + async function answeredUnopened() { + const rig = await codexTurnLifecycleRig({ legacyStartAnswers: true }) + expect(await rig.send('client-1')).toEqual({ state: 'admitted' }) + const sending = rig.send('client-2') + expect(await settledWithin(sending)).toBe('held') + const methods = () => rig.codex.connections[0]!.calls.map(({ method }) => method) + expect(methods()).toEqual(['thread/start', 'turn/start']) + return { ...rig, sending, methods } + } + + it('waits for that turn to open and steers it, so a Stop withdraws both', async () => { + const rig = await answeredUnopened() + + rig.turns.start() + + expect(await rig.sending).toEqual({ state: 'admitted' }) + expect(rig.methods()).toEqual(['thread/start', 'turn/start', 'turn/steer']) + expect(await rig.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).toEqual({ + cancelled: true + }) + expect(rig.settlements.map(({ clientMessageId }) => clientMessageId).sort()).toEqual([ + 'client-1', + 'client-2' + ]) + }) + + it('starts its own turn when that turn ends without opening', async () => { + const rig = await answeredUnopened() + + rig.turns.end('failed') + + expect(await rig.sending).toEqual({ state: 'admitted' }) + expect(rig.methods()).toEqual(['thread/start', 'turn/start', 'turn/start']) + }) +}) + +describe('a Codex send after a turn Codex answered and never opened', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('waits for that turn once, and never again', async () => { + const rig = await codexTurnLifecycleRig({ legacyStartAnswers: true }) + expect(await rig.send('client-1')).toEqual({ state: 'admitted' }) + // Before 0.148 a turn that fails before it starts reports only an `error`. + rig.turns.failUnopened('invalid turn settings') + + vi.useFakeTimers() + const waited = rig.send('client-2') + await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS) + expect(await waited).toEqual({ state: 'admitted' }) + vi.useRealTimers() + rig.turns.start() + rig.turns.echo('client-2') + rig.turns.end('completed') + + expect(await settledWithin(rig.send('client-3'))).toEqual({ state: 'admitted' }) + }) +}) diff --git a/src/main/codex/codex-subagent-activity.ts b/src/main/codex/codex-subagent-activity.ts index 9761f8634f8c..0a00297dff8a 100644 --- a/src/main/codex/codex-subagent-activity.ts +++ b/src/main/codex/codex-subagent-activity.ts @@ -6,12 +6,19 @@ // * `agentPath` is a tree path (`/root`, `/root/list_directory`); the trailing // segment is a semantic task name and the only label available. There is no // `thread/started` for a child, so nickname/role/depth do not exist. -// * `agentsStates` on `collabAgentToolCall` arrived empty (`{}`) throughout the -// probe, so nothing here reads it; child turn events own execution state. +// * Codex's DEFAULT multi-agent mode sends no `subAgentActivity` at all; a +// helper appears only in the `collabAgentToolCall` items that spawn, message, +// wait on or close it (read in `codex-collab-agent-tool-call.ts`). Either item +// announces the same child, keyed by its thread id, and child turn events own +// its execution state. // * `thread/tokenUsage/updated` reports a per-thread RUNNING TOTAL, so the // latest frame replaces the previous one — it is never accumulated. -import type { CodexThreadItem } from './codex-structured-item-translation' +import { + codexCollabHelperLabel, + readCodexCollabAgentToolCall +} from './codex-collab-agent-tool-call' +import type { CodexThreadItem } from './codex-thread-item-identity' export const CODEX_SUBAGENT_ITEM_TYPE = 'subAgentActivity' export const CODEX_TOKEN_USAGE_METHOD = 'thread/tokenUsage/updated' @@ -85,6 +92,66 @@ export function codexSubagentLabel(activity: CodexSubagentActivity): string | nu return trailing !== undefined && trailing.length > 0 ? trailing : null } +/** A child the item says exists, from either wire shape Codex announces one with. */ +export type CodexSubagentAnnouncement = { + agentThreadId: string + label: string | null + /** The item's turn is the one the child was spawned or messaged from. */ + namesParentTurn: boolean + /** The item is the spawn itself, so the thread that carried it spawned the child. */ + spawned: boolean +} + +/** Collab calls that hand a helper work from the caller's turn, as an `interacted` activity does. */ +const CODEX_COLLAB_TOOLS_THAT_TASK_A_HELPER: ReadonlySet = new Set([ + 'spawnAgent', + 'sendInput' +]) + +/** The children an item announces: a `subAgentActivity`'s, or every helper a collab call names, + * keyed by thread id so repeats announce one child. Neither the tree root nor the session's own + * thread is a child. Any call counts, since a helper whose spawn was never seen (compacted out of + * history) is known only from the calls on it. A spawn counts whatever its status: Codex reports + * `failed` for a helper that errored at birth, yet names the thread it created, and that thread + * can still run. */ +export function readCodexSubagentAnnouncements( + item: CodexThreadItem, + primaryThreadId: string | null +): CodexSubagentAnnouncement[] { + return readAnnouncements(item).filter(({ agentThreadId }) => agentThreadId !== primaryThreadId) +} + +function readAnnouncements(item: CodexThreadItem): CodexSubagentAnnouncement[] { + const activity = readCodexSubagentActivity(item) + if (activity) { + return isCodexRootAgentActivity(activity) + ? [] + : [ + { + agentThreadId: activity.agentThreadId, + label: codexSubagentLabel(activity), + namesParentTurn: activity.kind === 'started' || activity.kind === 'interacted', + spawned: activity.kind === 'started' + } + ] + } + const call = readCodexCollabAgentToolCall(item) + if (!call) { + return [] + } + const spawned = call.tool === 'spawnAgent' + // Only a spawn's prompt describes its helper; another call's prompt is a later message to it, + // so a helper no spawn named reads as any unnamed subagent does. + const label = spawned ? codexCollabHelperLabel(call.prompt) : null + const namesParentTurn = CODEX_COLLAB_TOOLS_THAT_TASK_A_HELPER.has(call.tool) + return call.helperThreadIds.map((agentThreadId) => ({ + agentThreadId, + label, + namesParentTurn, + spawned + })) +} + export type CodexThreadTokenTotal = { threadId: string; totalTokens: number } /** `{threadId, tokenUsage: {total: {totalTokens}}}`. Older builds put the total diff --git a/src/main/codex/codex-subagent-executions.ts b/src/main/codex/codex-subagent-executions.ts index 87d9327c70c0..0d0fe0c5bf4e 100644 --- a/src/main/codex/codex-subagent-executions.ts +++ b/src/main/codex/codex-subagent-executions.ts @@ -25,10 +25,20 @@ export type CodexExecutionChild = { turnCount: number } +/** Told when a child's current execution changes, whichever frame changed it. */ +export type CodexExecutionListener = (child: Readonly) => void + /** Child turn events own execution; activity items only identify the child. */ export class CodexSubagentExecutions { private readonly children = new Map() private readonly settledTurns = new Map() + private readonly listeners = new Set() + + /** Every reader of a child's execution follows it here, so none needs the frames that end it. */ + onExecutionChanged(listener: CodexExecutionListener): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } register( agentThreadId: string, @@ -90,6 +100,7 @@ export class CodexSubagentExecutions { } if (state === 'working' || !child.execution || child.execution.turnId === turnId) { child.execution = execution + this.changed(child) } return { child, execution } } @@ -133,6 +144,7 @@ export class CodexSubagentExecutions { for (const child of this.children.values()) { if (child.execution?.state === 'working') { child.execution = { ...child.execution, state: 'unverifiable' } + this.changed(child) } } } @@ -147,6 +159,12 @@ export class CodexSubagentExecutions { return { children: this.children.size, settledTurns: this.settledTurns.size } } + private changed(child: CodexExecutionChild): void { + for (const listener of this.listeners) { + listener(child) + } + } + private child(agentThreadId: string): CodexExecutionChild | undefined { const existing = this.children.get(agentThreadId) if (existing) { diff --git a/src/main/codex/codex-subagent-roster.ts b/src/main/codex/codex-subagent-roster.ts index db02ac7f4547..042183a6e6f8 100644 --- a/src/main/codex/codex-subagent-roster.ts +++ b/src/main/codex/codex-subagent-roster.ts @@ -1,6 +1,8 @@ // The Codex subagent roster: one journal row per spawn group, revised in place. // -// Activity supplies membership; child turn events supply execution state. +// An announcement supplies membership — a `subAgentActivity` item, or in +// Codex's default multi-agent mode any collab call naming the helper — and +// child turn events supply execution state. // // KNOWN LIMITATION: `groups` is process-local and is never seeded from the // journal, while the row's identity is keyed on the group id alone. So once a @@ -21,9 +23,9 @@ import type { StructuredAgentSessionSinkAdmission } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { - codexSubagentLabel, - isCodexRootAgentActivity, readCodexSubagentActivity, + readCodexSubagentAnnouncements, + type CodexSubagentAnnouncement, readCodexThreadTokenTotal } from './codex-subagent-activity' import { @@ -46,9 +48,9 @@ import { import type { CodexThreadItem } from './codex-structured-item-translation' import { MAX_CODEX_SUBAGENT_GROUPS, - MAX_CODEX_SUBAGENTS_PER_GROUP, - MAX_CODEX_TOKEN_USAGE_THREADS + MAX_CODEX_SUBAGENTS_PER_GROUP } from './codex-structured-journal-limits' +import { CodexThreadTokenTotals } from './codex-thread-token-totals' const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } @@ -64,48 +66,54 @@ export type CodexSubagentRosterDeps = { export class CodexSubagentRoster { private readonly groups = new Map() - /** Latest reported total per thread, kept regardless of roster membership: a - * usage frame can arrive before the child's first activity item, and filtering - * at receipt would lose it permanently. Children are selected at write time; - * the map itself is LRU-capped in `handleTokenUsage`. */ - private readonly tokensByThread = new Map() + /** Every thread's total, members or not; children are selected at write time. */ + private readonly tokensByThread = new CodexThreadTokenTotals() private readonly now: () => number - private readonly executions: CodexSubagentExecutions + /** The one owner of child membership and turn state; the rows of calls on a helper read it too. */ + readonly executions: CodexSubagentExecutions + private readonly unfollow: () => void /** Who produced a row, from what this roster learned about each child thread. */ readonly linkage: CodexSubagentLinkage constructor(private readonly deps: CodexSubagentRosterDeps) { this.now = deps.now ?? (() => Date.now()) this.executions = deps.executions ?? new CodexSubagentExecutions() + // The row follows the executions, so every frame that ends a child's turn — its own + // `turn/completed` (a failed one included) or its thread closing — settles it. + // A refused write clears `lastSerialized`, so the next write of the group retries it. + this.unfollow = this.executions.onExecutionChanged( + (child) => child.execution && this.follow(child, child.execution) + ) this.linkage = new CodexSubagentLinkage({ primaryThreadId: deps.primaryThreadId, executions: this.executions }) } - /** Consume a `subAgentActivity` item. Returns null when the item is not one. */ + /** Consume an item that announces children. Null means the item is not this roster's to render: + * a `subAgentActivity` item renders as the roster row alone, while a collab call keeps its own + * row, so it is claimed only to hand back a refused write. */ handleItem(input: { threadId: string turnId: string | null item: CodexThreadItem }): StructuredAgentSessionSinkAdmission | null { - const activity = readCodexSubagentActivity(input.item) - if (!activity) { - return null - } - // The root node is the parent turn itself, not a child it spawned. - if ( - activity.agentThreadId === this.deps.primaryThreadId() || - isCodexRootAgentActivity(activity) - ) { - return ADMITTED - } + const refused = readCodexSubagentAnnouncements(input.item, this.deps.primaryThreadId()) + .map((announcement) => this.announce(input, announcement)) + .find((admission) => !admission.accepted) + return refused ?? (readCodexSubagentActivity(input.item) !== null ? ADMITTED : null) + } + + private announce( + input: { threadId: string; turnId: string | null }, + announcement: CodexSubagentAnnouncement + ): StructuredAgentSessionSinkAdmission { const child = this.executions.register( - activity.agentThreadId, - codexSubagentLabel(activity), - activity.kind === 'started' || activity.kind === 'interacted' ? input.turnId : undefined, - // Only `started` names the spawner: other kinds ride whichever agent acted. - activity.kind === 'started' ? input.threadId : undefined + announcement.agentThreadId, + announcement.label, + announcement.namesParentTurn ? input.turnId : undefined, + // Only a spawn names the spawner: other announcements ride whichever agent acted. + announcement.spawned ? input.threadId : undefined ) if (!child?.execution) { return ADMITTED @@ -146,12 +154,19 @@ export class CodexSubagentRoster { return ADMITTED } const observed = this.executions.observeTurn(input.threadId, input.turnId, input.state) - if (!observed || !observed.child.registered) { + // Followed already if the execution changed; re-derived (idempotently) for its admission. + return observed ? this.follow(observed.child, observed.execution) : ADMITTED + } + + private follow( + child: Readonly, + execution: CodexChildExecution + ): StructuredAgentSessionSinkAdmission { + if (!child.registered) { return ADMITTED } - const { child, execution } = observed - if (input.state === 'working') { - const parent = this.deps.primaryThreadId() ?? input.threadId + if (execution.state === 'working') { + const parent = this.deps.primaryThreadId() ?? child.agentThreadId const group = this.executionGroup(child.agentThreadId, execution.turnId) ?? this.groupFor(parent, this.deps.activeTurn(parent) ?? child.parentTurnId) @@ -159,7 +174,7 @@ export class CodexSubagentRoster { return this.write(group) } for (const group of this.groups.values()) { - if (group.executionTurns.get(input.threadId) !== input.turnId) { + if (group.executionTurns.get(child.agentThreadId) !== execution.turnId) { continue } this.recordExecution(group, child, execution) @@ -177,19 +192,7 @@ export class CodexSubagentRoster { if (!usage) { return null } - // A running total: the newest frame REPLACES the previous one. Summing - // updates would multiply a single child's usage by its frame count. - // Re-insert so the eviction scan below sees recency: `set` on an existing - // key keeps its original position, which would age out an active thread. - this.tokensByThread.delete(usage.threadId) - this.tokensByThread.set(usage.threadId, usage.totalTokens) - while (this.tokensByThread.size > MAX_CODEX_TOKEN_USAGE_THREADS) { - const oldest = this.tokensByThread.keys().next().value - if (typeof oldest !== 'string') { - break - } - this.tokensByThread.delete(oldest) - } + this.tokensByThread.record(usage.threadId, usage.totalTokens) for (const group of this.groups.values()) { if (!group.entries.has(usage.threadId)) { continue @@ -222,6 +225,7 @@ export class CodexSubagentRoster { } dispose(): void { + this.unfollow() this.groups.clear() this.tokensByThread.clear() } diff --git a/src/main/codex/codex-thread-token-totals.test.ts b/src/main/codex/codex-thread-token-totals.test.ts new file mode 100644 index 000000000000..1dab7dc6f6e7 --- /dev/null +++ b/src/main/codex/codex-thread-token-totals.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { MAX_CODEX_TOKEN_USAGE_THREADS } from './codex-structured-journal-limits' +import { CodexThreadTokenTotals } from './codex-thread-token-totals' + +describe('CodexThreadTokenTotals', () => { + it("replaces a thread's total with its newest report instead of summing reports", () => { + const totals = new CodexThreadTokenTotals() + totals.record('child-1', 100) + totals.record('child-1', 250) + expect(totals.get('child-1')).toBe(250) + }) + + it('evicts by recency of report, so a thread still reporting outlives quieter ones', () => { + const totals = new CodexThreadTokenTotals() + totals.record('child-1', 1) + for (let index = 0; index < MAX_CODEX_TOKEN_USAGE_THREADS - 1; index++) { + totals.record(`other-${index}`, index) + } + totals.record('child-1', 2) + totals.record('newest', 3) + expect(totals.get('child-1')).toBe(2) + expect(totals.get('other-0')).toBeUndefined() + }) +}) diff --git a/src/main/codex/codex-thread-token-totals.ts b/src/main/codex/codex-thread-token-totals.ts new file mode 100644 index 000000000000..23bc6a9d1995 --- /dev/null +++ b/src/main/codex/codex-thread-token-totals.ts @@ -0,0 +1,32 @@ +import { MAX_CODEX_TOKEN_USAGE_THREADS } from './codex-structured-journal-limits' + +/** Latest reported token total per thread, kept regardless of roster membership: a + * usage frame can arrive before the child's first activity item, and filtering at + * receipt would lose it permanently. LRU-capped at MAX_CODEX_TOKEN_USAGE_THREADS. */ +export class CodexThreadTokenTotals { + private readonly totals = new Map() + + /** A running total: the newest frame REPLACES the previous one. Summing updates + * would multiply a single child's usage by its frame count. */ + record(threadId: string, totalTokens: number): void { + // Re-insert so eviction sees recency: `set` on an existing key keeps its + // original position, which would age out an active thread. + this.totals.delete(threadId) + this.totals.set(threadId, totalTokens) + while (this.totals.size > MAX_CODEX_TOKEN_USAGE_THREADS) { + const oldest = this.totals.keys().next().value + if (typeof oldest !== 'string') { + break + } + this.totals.delete(oldest) + } + } + + get(threadId: string): number | undefined { + return this.totals.get(threadId) + } + + clear(): void { + this.totals.clear() + } +} diff --git a/src/main/codex/codex-trust-config-concurrent-launch.test.ts b/src/main/codex/codex-trust-config-concurrent-launch.test.ts index 0132c4b267b1..1a3e230a091a 100644 --- a/src/main/codex/codex-trust-config-concurrent-launch.test.ts +++ b/src/main/codex/codex-trust-config-concurrent-launch.test.ts @@ -134,15 +134,12 @@ function writingSessionRunner(args: { } describe('two Codex pane launches against one config.toml', () => { - it('does not let a failing launch roll back a concurrent launch that already succeeded', async () => { - // Why warm: on a cold host the shared capability probe incidentally - // serializes the two launches. Once the host is known-supported that - // dedupe is bypassed and the per-file lane is the only thing left. + it("leaves a concurrent grant's records in place when a sibling grant fails", async () => { + // Why warm: on a cold host the shared capability probe serializes the two + // grants. Once the host is known-supported, their sessions overlap. codexAppServerCapabilityCache.rememberSupported('native') const tomlPath = join(runtimeHomeDir, 'config.toml') const entries = [managedEntry('session_start')] - let sessionsInFlight = 0 - let maxSessionsInFlight = 0 let call = 0 let releaseFirst!: () => void const firstGate = new Promise((resolve) => { @@ -150,21 +147,15 @@ describe('two Codex pane launches against one config.toml', () => { }) _internals.setGrantSessionRunner(async (request) => { - sessionsInFlight += 1 - maxSessionsInFlight = Math.max(maxSessionsInFlight, sessionsInFlight) call += 1 const isFirst = call === 1 - try { - return await writingSessionRunner({ - tomlPath, - entries, - hashPrefix: isFirst ? 'sha256:doomed-' : 'sha256:survivor-', - gate: isFirst ? firstGate : undefined, - outcome: isFirst ? 'verify-failed' : 'granted' - })(request) - } finally { - sessionsInFlight -= 1 - } + return writingSessionRunner({ + tomlPath, + entries, + hashPrefix: isFirst ? 'sha256:doomed-' : 'sha256:survivor-', + gate: isFirst ? firstGate : undefined, + outcome: isFirst ? 'verify-failed' : 'granted' + })(request) }) const doomed = grantManagedCodexHookTrust(buildPlan(entries)) @@ -175,15 +166,13 @@ describe('two Codex pane launches against one config.toml', () => { expect(await doomed).toMatchObject({ lane: 'fallback', reason: 'verify-failed' }) expect(await survivor).toMatchObject({ lane: 'rpc' }) - // The doomed run's rollback must not resurrect the pre-grant file over - // the entries the survivor legitimately wrote. + // Why: a failed grant writes nothing back, so the survivor's records stay. const trust = readHookTrustEntries(tomlPath) const key = normalizeHookTrustKeyForLookup(computeTrustKey(entries[0])) expect(trust.get(key)?.trustedHash).toBe('sha256:survivor-session_start') - expect(maxSessionsInFlight).toBe(1) }) - it('keeps a concurrent markCodexProjectTrusted write out of a grant rollback window', async () => { + it('writes project trust while a grant session is still running, and keeps it', async () => { codexAppServerCapabilityCache.rememberSupported('native') const tomlPath = join(runtimeHomeDir, 'config.toml') const entries = [managedEntry('session_start')] @@ -205,21 +194,14 @@ describe('two Codex pane launches against one config.toml', () => { try { const grant = grantManagedCodexHookTrust(buildPlan(entries)) - // Let the grant capture config.toml and start its session. await tick() await tick() - const marked = markCodexProjectTrusted( - workspace, - getLocalCodexTrustConfigFiles(testState.fakeHomeDir) - ) - await tick() - // The lane must hold the preset write back until rollback has run. - expect(readFileSync(tomlPath, 'utf-8')).not.toContain('trust_level') + // Why: the grant holds no lane across its session, so a launch's write lands at once. + await markCodexProjectTrusted(workspace, getLocalCodexTrustConfigFiles(testState.fakeHomeDir)) + expect(readFileSync(tomlPath, 'utf-8')).toContain('trust_level = "trusted"') releaseSession() expect(await grant).toMatchObject({ lane: 'fallback', reason: 'verify-failed' }) - await marked - expect(readFileSync(tomlPath, 'utf-8')).toContain('trust_level = "trusted"') } finally { rmSync(workspace, { recursive: true, force: true }) diff --git a/src/main/codex/codex-trust-config-mutation-queue.ts b/src/main/codex/codex-trust-config-mutation-queue.ts index 08aa3db4316d..b89ed84cce49 100644 --- a/src/main/codex/codex-trust-config-mutation-queue.ts +++ b/src/main/codex/codex-trust-config-mutation-queue.ts @@ -7,15 +7,20 @@ const tailByTomlPath = new Map>() // one and pass through instead of queueing behind itself forever. const heldKeys = new AsyncLocalStorage>() +/** Runs `run` holding no lane, so work it starts queues like any new caller. */ +export function runOutsideCodexTrustConfigLanes(run: () => T): T { + return heldKeys.exit(run) +} + /** - * Serializes everything that mutates one Codex `config.toml` — hook installs, - * trust grants, and user-hook rebases — as a single lane per file. + * Serializes Orca's own multi-step mutations of one Codex `config.toml` — hook + * installs and their trust fallbacks, trust moves, and project trust — as a + * single lane per file. A Codex trust session holds no lane: Codex writes its + * own records. * * Why (#16441): these used to block the main thread, so two of them could * never be in flight at once. Now that they await, a second run could write - * the file between another run's capture and its restore-on-failure, undoing - * a mutation that run never made and resurrecting trust it deliberately - * removed. + * the file between another run's read and its dependent write. */ export function runExclusivelyForCodexTrustConfig( tomlPath: string, diff --git a/src/main/codex/codex-trust-config-rollback.test.ts b/src/main/codex/codex-trust-config-rollback.test.ts deleted file mode 100644 index 0eae14ff9865..000000000000 --- a/src/main/codex/codex-trust-config-rollback.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { - chmodSync, - lstatSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - statSync, - symlinkSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { captureCodexTrustConfig, restoreCodexTrustConfig } from './codex-trust-config-rollback' - -const roots: string[] = [] - -afterEach(() => { - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -function tempConfigPath(): string { - const root = mkdtempSync(join(tmpdir(), 'orca-codex-rollback-')) - roots.push(root) - return join(root, 'config.toml') -} - -describe('Codex trust config rollback', () => { - it('treats a missing config as absent and tolerates it remaining absent', () => { - const configPath = tempConfigPath() - const snapshot = captureCodexTrustConfig(configPath) - - expect(snapshot).toEqual({ existed: false }) - expect(() => restoreCodexTrustConfig(configPath, snapshot)).not.toThrow() - }) - - it('removes a config created after an absent snapshot', () => { - const configPath = tempConfigPath() - const snapshot = captureCodexTrustConfig(configPath) - writeFileSync(configPath, 'rpc mutation') - - restoreCodexTrustConfig(configPath, snapshot) - expect(() => readFileSync(configPath)).toThrowError(/ENOENT/) - }) - - // Why: ordinary Windows CI tokens cannot create file symlinks without Developer Mode. - it.skipIf(process.platform === 'win32')( - 'removes an RPC-created dangling-symlink target without deleting the user link', - () => { - const configPath = tempConfigPath() - const targetDir = join(configPath, '..', 'dotfiles') - const targetPath = join(targetDir, 'codex-config.toml') - mkdirSync(targetDir) - symlinkSync(join('dotfiles', 'codex-config.toml'), configPath) - const snapshot = captureCodexTrustConfig(configPath) - writeFileSync(targetPath, 'rpc mutation') - - restoreCodexTrustConfig(configPath, snapshot) - - expect(lstatSync(configPath).isSymbolicLink()).toBe(true) - expect(() => readFileSync(targetPath)).toThrowError(/ENOENT/) - } - ) - - it('atomically recreates exact contents and mode after the file disappears', () => { - const configPath = tempConfigPath() - const original = Buffer.from('# comment\r\n[hooks]\r\n') - writeFileSync(configPath, original) - chmodSync(configPath, 0o640) - const snapshot = captureCodexTrustConfig(configPath) - rmSync(configPath) - - restoreCodexTrustConfig(configPath, snapshot) - - expect(readFileSync(configPath)).toEqual(original) - if (process.platform !== 'win32') { - expect(statSync(configPath).mode & 0o777).toBe(0o640) - } - }) - - it.skipIf(process.platform === 'win32')( - 'restores a symlink target without replacing the config.toml symlink', - () => { - const configPath = tempConfigPath() - const targetDir = join(configPath, '..', 'dotfiles') - const targetPath = join(targetDir, 'codex-config.toml') - mkdirSync(targetDir) - writeFileSync(targetPath, '# original\n') - symlinkSync(targetPath, configPath) - const snapshot = captureCodexTrustConfig(configPath) - rmSync(targetPath) - - restoreCodexTrustConfig(configPath, snapshot) - - expect(lstatSync(configPath).isSymbolicLink()).toBe(true) - expect(readFileSync(targetPath, 'utf8')).toBe('# original\n') - } - ) - - it.skipIf(process.platform === 'win32')( - 'restores the captured mode when the contents already match', - () => { - const configPath = tempConfigPath() - writeFileSync(configPath, '[hooks]\n') - chmodSync(configPath, 0o640) - const snapshot = captureCodexTrustConfig(configPath) - chmodSync(configPath, 0o600) - - restoreCodexTrustConfig(configPath, snapshot) - - expect(readFileSync(configPath, 'utf8')).toBe('[hooks]\n') - expect(statSync(configPath).mode & 0o777).toBe(0o640) - } - ) -}) diff --git a/src/main/codex/codex-trust-config-rollback.ts b/src/main/codex/codex-trust-config-rollback.ts deleted file mode 100644 index 0642b8344831..000000000000 --- a/src/main/codex/codex-trust-config-rollback.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { - chmodSync, - closeSync, - fstatSync, - lstatSync, - openSync, - readFileSync, - readlinkSync, - realpathSync, - unlinkSync, - writeFileSync -} from 'node:fs' -import { randomUUID } from 'node:crypto' -import { dirname, resolve } from 'node:path' -import { renameFileWithWindowsRetry } from '../codex-accounts/fs-utils' - -export type CodexTrustConfigSnapshot = - | { existed: false; restorePath?: string } - | { existed: true; contents: Buffer; mode: number; restorePath: string } - -function resolveConfigRestorePath(tomlPath: string): string { - try { - if (!lstatSync(tomlPath).isSymbolicLink()) { - return tomlPath - } - try { - return realpathSync.native(tomlPath) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error - } - // Why: a dangling dotfiles link is still user-owned state. Target the - // lexical destination so rollback removes an RPC-created file, not the link. - return resolve(dirname(tomlPath), readlinkSync(tomlPath)) - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return tomlPath - } - throw error - } -} - -export function captureCodexTrustConfig(tomlPath: string): CodexTrustConfigSnapshot { - const restorePath = resolveConfigRestorePath(tomlPath) - let descriptor: number - try { - descriptor = openSync(restorePath, 'r') - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return restorePath === tomlPath ? { existed: false } : { existed: false, restorePath } - } - throw error - } - try { - // Why: read and stat the same open file so replacement between two path - // lookups cannot pair one file's contents with another file's mode. - return { - existed: true, - contents: readFileSync(descriptor), - mode: fstatSync(descriptor).mode, - restorePath - } - } finally { - closeSync(descriptor) - } -} - -export function restoreCodexTrustConfig( - tomlPath: string, - snapshot: CodexTrustConfigSnapshot -): void { - if (!snapshot.existed) { - try { - unlinkSync(snapshot.restorePath ?? tomlPath) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error - } - } - return - } - const { restorePath } = snapshot - try { - if (readFileSync(restorePath).equals(snapshot.contents)) { - // Why: the RPC may change permissions without changing bytes; rollback - // restores the complete captured file state, not only its contents. - chmodSync(restorePath, snapshot.mode) - return - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error - } - } - // Why: rollback protects config integrity too; direct truncating writes can - // leave Codex unusable if Orca exits midway through recovery. - // Why: Codex's writer preserves config.toml symlinks. Restore through their - // real target too, or Orca's atomic rename would disconnect dotfiles users. - const tempPath = `${restorePath}.${process.pid}.${randomUUID()}.rollback.tmp` - try { - writeFileSync(tempPath, snapshot.contents, { mode: snapshot.mode }) - renameFileWithWindowsRetry(tempPath, restorePath) - } catch (error) { - try { - unlinkSync(tempPath) - } catch { - // Best effort; preserve the rollback failure as the actionable error. - } - throw error - } -} diff --git a/src/main/codex/codex-trust-grant-cooldown.ts b/src/main/codex/codex-trust-grant-cooldown.ts new file mode 100644 index 000000000000..298f92b4d3c7 --- /dev/null +++ b/src/main/codex/codex-trust-grant-cooldown.ts @@ -0,0 +1,47 @@ +import type { CodexAppServerHostKey } from './codex-app-server-capability-cache' + +// Why: a transiently hung app-server must not block launch prep on every pane. +// The legacy lane remains available while a short, host-scoped cooldown runs. +export const CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS = 5 * 60_000 +const MAX_TRANSIENT_TRUST_COOLDOWNS = 256 + +// Why launch-path grants only: a background grant's retry is scheduled by its +// caller's lane, so a second schedule here could only disagree with it. +const retryAfterByHost = new Map() + +export function isCodexTrustGrantCoolingDown(hostKey: CodexAppServerHostKey): boolean { + const retryAfter = retryAfterByHost.get(hostKey) + if (retryAfter === undefined) { + return false + } + if (Date.now() < retryAfter) { + return true + } + retryAfterByHost.delete(hostKey) + return false +} + +export function startCodexTrustGrantCooldown(hostKey: CodexAppServerHostKey): void { + retryAfterByHost.delete(hostKey) + retryAfterByHost.set(hostKey, Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS) + while (retryAfterByHost.size > MAX_TRANSIENT_TRUST_COOLDOWNS) { + const oldest = retryAfterByHost.keys().next().value + if (oldest === undefined) { + break + } + retryAfterByHost.delete(oldest) + } +} + +/** A success or a proven-missing surface, from either lane, ends the host's cooldown. */ +export function clearCodexTrustGrantCooldown(hostKey: CodexAppServerHostKey): void { + retryAfterByHost.delete(hostKey) +} + +export function resetCodexTrustGrantCooldowns(): void { + retryAfterByHost.clear() +} + +export function countCodexTrustGrantCooldowns(): number { + return retryAfterByHost.size +} diff --git a/src/main/codex/codex-trust-grant-host.ts b/src/main/codex/codex-trust-grant-host.ts index d7fb8e459752..2a17adaff166 100644 --- a/src/main/codex/codex-trust-grant-host.ts +++ b/src/main/codex/codex-trust-grant-host.ts @@ -28,6 +28,8 @@ type CodexTrustGrantRequestInput = { managedCommand: string expectedTrustKeys: string[] useDefaultCodexHome?: boolean + /** Overrides the native deadline; WSL keeps its own. */ + timeoutMs?: number } export type ResolvedCodexTrustGrantHost = { @@ -81,7 +83,7 @@ export function resolveNativeCodexTrustGrantHost(): ResolvedCodexTrustGrantHost ...(useDefaultCodexHome ? { envToDelete: ['CODEX_HOME'] } : { env: { CODEX_HOME: input.runtimeHomePath } }), - timeoutMs: NATIVE_GRANT_TIMEOUT_MS + timeoutMs: input.timeoutMs ?? NATIVE_GRANT_TIMEOUT_MS }, hooksListCwd: input.runtimeHomePath, expectedTrustKeys: input.expectedTrustKeys, diff --git a/src/main/codex/codex-trust-grant-ledger.ts b/src/main/codex/codex-trust-grant-ledger.ts index 75a289db22ff..08d1098296e2 100644 --- a/src/main/codex/codex-trust-grant-ledger.ts +++ b/src/main/codex/codex-trust-grant-ledger.ts @@ -4,12 +4,12 @@ import { dirname, join } from 'node:path' import { getOrcaManagedCodexHomePath } from './codex-home-paths' import { normalizeCodexProjectPathForLookup } from './config-toml-trust' -// Why: a grant session blocks launch prep, so it must not run on every pane -// launch. This ledger records what a *verified* codex-side grant left behind -// (per runtime home): the hook identity that was granted, the Codex-computed -// hash, and the codex binary that computed it. Install skips the RPC while -// all three still hold; any drift (hook edit, config wipe, codex upgrade) -// re-triggers a grant before the pane launches. +// Why: an inline grant session blocks launch prep, and a background one still +// starts an app-server, so neither may run on every pane launch. This ledger +// records what a *verified* codex-side grant left behind (per runtime home): +// the hook identity that was granted, the Codex-computed hash, and the codex +// binary that computed it. Install skips the RPC while all three still hold; +// any drift (hook edit, config wipe, codex upgrade) re-triggers a grant. export type CodexTrustGrantBinaryStamp = | { kind: 'native'; path: string; size: number; mtimeMs: number } diff --git a/src/main/codex/codex-turn-lifecycle-fake.ts b/src/main/codex/codex-turn-lifecycle-fake.ts index 4ed2507640ba..a958faca1eaa 100644 --- a/src/main/codex/codex-turn-lifecycle-fake.ts +++ b/src/main/codex/codex-turn-lifecycle-fake.ts @@ -1,9 +1,12 @@ -// Codex 0.157's turn bookkeeping as `turn/start` and `turn/interrupt` see it, for -// tests. From app-server `turn_processor.rs`: `turn/start` picks the turn before it +// Codex 0.157's turn bookkeeping as `turn/start`, `turn/steer` and `turn/interrupt` see +// it, for tests. From app-server `turn_processor.rs`: `turn/start` picks the turn before it // answers, a send while a turn is open is steered into it under the same id with no -// second `turn/started`, and `turn_interrupt_inner` refuses with -32600 until the +// second `turn/started`, `turn/steer` refuses with -32600 unless its `expectedTurnId` is +// the running turn, and `turn_interrupt_inner` refuses with -32600 until the // turn has started. The answer can be held, so a test can deliver it after the -// turn's own frames, as the wire allows. +// turn's own frames, as the wire allows. `legacyStartAnswers` is a Codex before 0.148, +// whose `turn/start` answers a steered send with its submission id, a turn that never opens, +// and whose `turn/steer` finds no turn to steer until the picked one has started. import { CodexAppServerRequestError } from './codex-app-server-connection' @@ -12,6 +15,7 @@ type Notify = (method: string, params: unknown) => void export type CodexTurnLifecycleFake = { routes: { 'turn/start': () => unknown + 'turn/steer': (params: Record | undefined) => unknown 'turn/interrupt': (params: Record | undefined) => unknown } /** The next `turn/start` answer waits until the returned release runs. */ @@ -20,23 +24,26 @@ export type CodexTurnLifecycleFake = { start: () => void /** Codex ends the picked or running turn on its own. */ end: (status: 'completed' | 'interrupted' | 'failed', errorMessage?: string) => void + /** Codex fails the picked turn before starting it, which reports an `error` and no turn end. */ + failUnopened: (message: string) => void /** Codex echoes a user message it recorded in the current turn. */ echo: (clientId: string) => void readonly turnId: string | null } -function refusal(message: string): CodexAppServerRequestError { +function refusal(method: string, message: string): CodexAppServerRequestError { return new CodexAppServerRequestError( - 'turn/interrupt', + method, -32600, - `codex app-server turn/interrupt failed: ${message}`, + `codex app-server ${method} failed: ${message}`, message ) } export function codexTurnLifecycleFake( threadId: string, - notify: () => Notify + notify: () => Notify, + options: { legacyStartAnswers?: boolean } = {} ): CodexTurnLifecycleFake { let minted = 0 let echoes = 0 @@ -55,21 +62,40 @@ export function codexTurnLifecycleFake( return { routes: { 'turn/start': () => { - const turnId = active ?? picked ?? `turn-${++minted}` + const running = active ?? picked + const turnId = running ?? `turn-${++minted}` picked ??= active ? null : turnId lastTurn = turnId - const answer = { turn: { id: turnId, status: 'inProgress' } } + const answeredId = options.legacyStartAnswers && running ? `turn-${++minted}` : turnId + const answer = { turn: { id: answeredId, status: 'inProgress' } } const wait = held held = null return wait ? wait.then(() => answer) : answer }, + 'turn/steer': (params) => { + const running = options.legacyStartAnswers ? active : (active ?? picked) + if (!running) { + throw refusal('turn/steer', 'no active turn to steer') + } + if (params?.expectedTurnId !== running) { + throw refusal( + 'turn/steer', + `expected active turn id \`${String(params?.expectedTurnId)}\` but found \`${running}\`` + ) + } + lastTurn = running + return { turnId: running } + }, 'turn/interrupt': (params) => { const turnId = params?.turnId if (!active) { - throw refusal('no active turn to interrupt') + throw refusal('turn/interrupt', 'no active turn to interrupt') } if (active !== turnId) { - throw refusal(`expected active turn id ${String(turnId)} but found ${active}`) + throw refusal( + 'turn/interrupt', + `expected active turn id ${String(turnId)} but found ${active}` + ) } // Codex answers the interrupt once the turn has aborted. finish(active, 'interrupted') @@ -97,6 +123,15 @@ export function codexTurnLifecycleFake( } finish(turnId, status, errorMessage) }, + failUnopened: (message) => { + if (!picked || active) { + throw new Error('no unopened turn to fail') + } + const turnId = picked + picked = null + notify()('error', { threadId, turnId, error: { message }, willRetry: false }) + notify()('thread/status/changed', { threadId, status: { type: 'systemError' } }) + }, echo: (clientId) => { const turnId = active ?? picked ?? lastTurn notify()('item/completed', { diff --git a/src/main/codex/codex-user-hook-trust-moves.test.ts b/src/main/codex/codex-user-hook-trust-moves.test.ts new file mode 100644 index 000000000000..60e86e2d1ef6 --- /dev/null +++ b/src/main/codex/codex-user-hook-trust-moves.test.ts @@ -0,0 +1,228 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { HookCommandConfig, HookDefinition } from '../agent-hooks/installer-utils' +import { + computeTrustKey, + readHookTrustEntries, + upsertHookTrustEntries, + type CodexTrustEntry +} from './config-toml-trust' +import { + getMovedCodexUserHookTrust, + mutateRealHomeHooksPreservingUserTrust +} from './codex-user-hook-trust-moves' + +let root: string +let hooksPath: string +let configPath: string + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-user-hook-trust-moves-')) + hooksPath = join(root, 'hooks.json') + configPath = join(root, 'config.toml') +}) + +afterEach(() => { + vi.restoreAllMocks() + rmSync(root, { recursive: true, force: true }) +}) + +function command(command: string): HookCommandConfig { + return { type: 'command', command } +} + +function stopEntry(groupIndex: number, hook: HookCommandConfig): CodexTrustEntry { + return { + sourcePath: hooksPath, + eventLabel: 'stop', + groupIndex, + handlerIndex: 0, + command: hook.command! + } +} + +function mutate( + before: Record, + after: Record +): void { + mutateRealHomeHooksPreservingUserTrust({ + sourcePath: hooksPath, + tomlPath: configPath, + beforeHooks: before, + afterHooks: after, + writeHooks: () => writeFileSync(hooksPath, JSON.stringify({ hooks: after })) + }) +} + +describe('real-home user hook trust rebasing', () => { + it('writes without touching config.toml when no hook moves', () => { + const user = command('user-hook') + mutate( + { Stop: [{ hooks: [user] }] }, + { Stop: [{ hooks: [user] }, { hooks: [command('orca')] }] } + ) + + expect(existsSync(hooksPath)).toBe(true) + expect(existsSync(configPath)).toBe(false) + }) + + it('finds multiple shifted user hooks, including a handler from a mixed group', async () => { + const orca = command('orca-hook') + const first = command('first-user') + const second = command('second-user') + const mixed = command('mixed-user') + const before: Record = { + Stop: [{ hooks: [orca] }, { hooks: [first] }, { hooks: [second] }, { hooks: [orca, mixed] }] + } + const after: Record = { + Stop: [{ hooks: [first] }, { hooks: [second] }, { hooks: [mixed] }] + } + + expect(getMovedCodexUserHookTrust(hooksPath, before, after)).toEqual([ + expect.objectContaining({ + command: 'first-user', + oldKey: expect.stringContaining(':1:0'), + newKey: expect.stringContaining(':0:0') + }), + expect.objectContaining({ + command: 'second-user', + oldKey: expect.stringContaining(':2:0'), + newKey: expect.stringContaining(':1:0') + }), + expect.objectContaining({ + command: 'mixed-user', + oldKey: expect.stringContaining(':3:1'), + newKey: expect.stringContaining(':2:0') + }) + ]) + }) + + it("moves each shifted hook's trust block to its new key, bytes unchanged", () => { + const orca = command('orca-hook') + const trusted = command('trusted-user') + const disabled = command('disabled-user') + const untrusted = command('untrusted-user') + upsertHookTrustEntries(configPath, [ + { ...stopEntry(0, orca), trustedHash: 'sha256:orca' }, + { ...stopEntry(1, trusted), trustedHash: 'sha256:from-an-older-codex' }, + { ...stopEntry(2, disabled), trustedHash: 'sha256:disabled', enabled: false } + ]) + writeFileSync(configPath, `model = "user-model"\n${readFileSync(configPath, 'utf-8')}`) + + mutate( + { + Stop: [ + { hooks: [orca] }, + { hooks: [trusted] }, + { hooks: [disabled] }, + { hooks: [untrusted] } + ] + }, + { Stop: [{ hooks: [trusted] }, { hooks: [disabled] }, { hooks: [untrusted] }] } + ) + + const trust = readHookTrustEntries(configPath) + expect(trust.get(computeTrustKey(stopEntry(0, trusted)))).toEqual({ + trustedHash: 'sha256:from-an-older-codex', + enabled: true + }) + expect(trust.get(computeTrustKey(stopEntry(1, disabled)))).toEqual({ + trustedHash: 'sha256:disabled', + enabled: false + }) + // Why: an untrusted hook keeps no record, and the key it vacated holds none. + expect(trust.get(computeTrustKey(stopEntry(2, untrusted)))).toBeUndefined() + expect(trust.get(computeTrustKey(stopEntry(3, untrusted)))).toBeUndefined() + expect(readFileSync(configPath, 'utf-8').startsWith('model = "user-model"\n')).toBe(true) + }) + + it('keeps the hooks write and reports when config.toml cannot be written', () => { + const orca = command('orca-hook') + const user = command('user-hook') + upsertHookTrustEntries(configPath, [{ ...stopEntry(1, user), trustedHash: 'sha256:user' }]) + rmSync(configPath) + mkdirSync(configPath) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + mutate({ Stop: [{ hooks: [orca] }, { hooks: [user] }] }, { Stop: [{ hooks: [user] }] }) + + expect(JSON.parse(readFileSync(hooksPath, 'utf-8')).hooks.Stop).toEqual([{ hooks: [user] }]) + expect(warn).toHaveBeenCalled() + }) + + it('moves nothing when any stored key has an unknown shape, leaving review to Codex', () => { + const orca = command('orca-hook') + const user = command('user-hook') + upsertHookTrustEntries(configPath, [{ ...stopEntry(1, user), trustedHash: 'sha256:user' }]) + const before = `${readFileSync(configPath, 'utf-8')}\n[hooks.state."a-key-in-a-new-format"]\ntrusted_hash = "sha256:other"\n` + writeFileSync(configPath, before) + + mutate({ Stop: [{ hooks: [orca] }, { hooks: [user] }] }, { Stop: [{ hooks: [user] }] }) + + expect(JSON.parse(readFileSync(hooksPath, 'utf-8')).hooks.Stop).toEqual([{ hooks: [user] }]) + expect(readFileSync(configPath, 'utf-8')).toBe(before) + }) + + // Why POSIX only: the expected bytes spell the key the way a POSIX path is written. + it.skipIf(process.platform === 'win32')( + 'rewrites only the moved blocks, carrying each body verbatim and computing nothing', + () => { + const orca = command('orca-hook') + const user = command('user-hook') + const other = command('other-event-hook') + const userBody = + 'trusted_hash = "sha256:written-by-codex"\nnote = "a field Orca does not know"' + const unrelated = [ + 'model = "user-model" # the user\'s own comment', + '', + '[projects."/work/app"]', + 'trust_level = "trusted"', + '', + `[hooks.state."${hooksPath}:post_tool_use:0:0"]`, + 'trusted_hash = "sha256:untouched"', + '' + ].join('\n') + writeFileSync( + configPath, + `${unrelated}\n[hooks.state."${hooksPath}:stop:1:0"]\n${userBody}\n` + ) + + mutate( + { Stop: [{ hooks: [orca] }, { hooks: [user] }], PostToolUse: [{ hooks: [other] }] }, + { Stop: [{ hooks: [user] }], PostToolUse: [{ hooks: [other] }] } + ) + + const after = readFileSync(configPath, 'utf-8') + expect(after.startsWith(unrelated)).toBe(true) + expect(after.slice(unrelated.length)).toBe( + `\n[hooks.state."${hooksPath}:stop:0:0"]\n${userBody}\n` + ) + } + ) + + // Why: Codex's own writes spell the key path quoted (#22592); a missed spelling here leaves a stale table. + it.skipIf(process.platform === 'win32')( + 'moves a block in Codex quoted spelling verbatim, leaving no second table behind', + () => { + const orca = command('orca-hook') + const user = command('user-hook') + const userBody = 'enabled = false\ntrusted_hash = "sha256:written-by-codex"' + const unrelated = ['model = "user-model"', '', '["hooks"."state"]', ''].join('\n') + writeFileSync( + configPath, + `${unrelated}\n["hooks"."state"."${hooksPath}:stop:1:0"]\n${userBody}\n` + ) + + mutate({ Stop: [{ hooks: [orca] }, { hooks: [user] }] }, { Stop: [{ hooks: [user] }] }) + + const after = readFileSync(configPath, 'utf-8') + expect(after).toBe(`${unrelated}\n[hooks.state."${hooksPath}:stop:0:0"]\n${userBody}\n`) + expect(readHookTrustEntries(configPath).get(computeTrustKey(stopEntry(0, user)))).toEqual({ + trustedHash: 'sha256:written-by-codex', + enabled: false + }) + } + ) +}) diff --git a/src/main/codex/codex-user-hook-trust-moves.ts b/src/main/codex/codex-user-hook-trust-moves.ts new file mode 100644 index 000000000000..6182de8a439e --- /dev/null +++ b/src/main/codex/codex-user-hook-trust-moves.ts @@ -0,0 +1,86 @@ +import type { HookCommandConfig, HookDefinition } from '../agent-hooks/installer-utils' +import { createCodexHookTrustEntry } from './codex-hook-identity' +import { computeTrustKey, moveHookTrustEntries, type CodexTrustEntry } from './config-toml-trust' + +type HooksByEvent = Record + +export type CodexUserHookTrustMove = { + oldKey: string + newKey: string + command: string +} + +function entriesByHookObject( + sourcePath: string, + hooksByEvent: HooksByEvent +): Map { + const result = new Map() + for (const [eventName, definitions] of Object.entries(hooksByEvent)) { + if (!Array.isArray(definitions)) { + continue + } + definitions.forEach((definition, groupIndex) => { + if (!Array.isArray(definition.hooks)) { + return + } + definition.hooks.forEach((hook, handlerIndex) => { + const entry = createCodexHookTrustEntry( + sourcePath, + eventName, + groupIndex, + handlerIndex, + definition, + hook + ) + if (entry) { + result.set(hook, entry) + } + }) + }) + } + return result +} + +export function getMovedCodexUserHookTrust( + sourcePath: string, + beforeHooks: HooksByEvent, + afterHooks: HooksByEvent +): CodexUserHookTrustMove[] { + const before = entriesByHookObject(sourcePath, beforeHooks) + const after = entriesByHookObject(sourcePath, afterHooks) + const moves: CodexUserHookTrustMove[] = [] + for (const [hook, oldEntry] of before) { + const newEntry = after.get(hook) + if (!newEntry) { + continue + } + const oldKey = computeTrustKey(oldEntry) + const newKey = computeTrustKey(newEntry) + if (oldKey !== newKey) { + moves.push({ oldKey, newKey, command: oldEntry.command }) + } + } + return moves +} + +/** + * Writes hooks.json, then moves the trust of every hook the write shifted to + * its new key, verbatim. Needs no Codex session, so no removal waits on one. + */ +export function mutateRealHomeHooksPreservingUserTrust(args: { + sourcePath: string + tomlPath: string + beforeHooks: HooksByEvent + afterHooks: HooksByEvent + writeHooks: () => void +}): void { + const moves = getMovedCodexUserHookTrust(args.sourcePath, args.beforeHooks, args.afterHooks) + args.writeHooks() + try { + moveHookTrustEntries(args.tomlPath, moves) + } catch (error) { + // Why no rollback: the hooks write is what was asked for; Codex lists the + // moved hooks for review, which the user can approve there. + console.warn('[codex-user-hook-trust] could not move shifted user hook trust:', error) + } +} diff --git a/src/main/codex/codex-user-hook-trust-rebase-client.test.ts b/src/main/codex/codex-user-hook-trust-rebase-client.test.ts deleted file mode 100644 index 65c610e84049..000000000000 --- a/src/main/codex/codex-user-hook-trust-rebase-client.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as CodexAppServerSession from './codex-app-server-session' -import type { CodexAppServerRpc } from './codex-app-server-session' - -const requestRpcMock = vi.hoisted(() => vi.fn()) -vi.mock('./codex-app-server-session', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - runCodexAppServerSession: vi.fn((_invocation, run) => - run({ request: requestRpcMock, notify: vi.fn() }) - ) - } -}) - -import { runCodexUserHookTrustRebaseSession } from './codex-user-hook-trust-rebase-client' - -const invocation = { command: 'codex', cliPath: null, args: ['app-server'], timeoutMs: 1000 } -const oldTrusted = '/home/a/.codex/hooks.json:stop:1:0' -const oldUntrusted = '/home/a/.codex/hooks.json:stop:2:0' -const newTrusted = '/home/a/.codex/hooks.json:stop:0:0' -const newUntrusted = '/home/a/.codex/hooks.json:stop:1:0' - -function listing(key: string, command: string, trustStatus: string, enabled = true) { - return { key, command, currentHash: `sha256:${key}`, trustStatus, enabled } -} - -function listResult(hooks: ReturnType[]) { - return { data: [{ cwd: '/tmp', hooks }] } -} - -beforeEach(() => requestRpcMock.mockReset()) - -describe('Codex user hook trust rebase RPCs', () => { - it('captures trusted, untrusted, and disabled states without writing config', async () => { - requestRpcMock.mockResolvedValueOnce( - listResult([ - listing(oldTrusted, 'trusted-user', 'trusted'), - listing(oldUntrusted, 'untrusted-user', 'untrusted', false) - ]) - ) - const result = await runCodexUserHookTrustRebaseSession({ - operation: 'inspect-user-hook-trust', - invocation, - hooksListCwd: '/tmp', - moves: [ - { oldKey: oldTrusted, newKey: newTrusted, command: 'trusted-user' }, - { oldKey: oldUntrusted, newKey: newUntrusted, command: 'untrusted-user' } - ] - }) - - expect(result).toEqual({ - outcome: 'inspected', - moves: [ - expect.objectContaining({ command: 'trusted-user', wasTrusted: true, enabled: true }), - expect.objectContaining({ command: 'untrusted-user', wasTrusted: false, enabled: false }) - ] - }) - expect(requestRpcMock).toHaveBeenCalledTimes(1) - }) - - it('clears shifted states, re-grants only prior trust, and verifies by re-list', async () => { - const postMutation = listResult([ - listing(newTrusted, 'trusted-user', 'untrusted'), - listing(newUntrusted, 'untrusted-user', 'trusted') - ]) - const verified = listResult([ - listing(newTrusted, 'trusted-user', 'trusted'), - listing(newUntrusted, 'untrusted-user', 'untrusted', false) - ]) - requestRpcMock - .mockResolvedValueOnce(postMutation) - .mockResolvedValueOnce({ status: 'ok' }) - .mockResolvedValueOnce(verified) - - await expect( - runCodexUserHookTrustRebaseSession({ - operation: 'repair-user-hook-trust', - invocation, - hooksListCwd: '/tmp', - moves: [ - { - oldKey: oldTrusted, - newKey: newTrusted, - command: 'trusted-user', - reportedOldKey: oldTrusted, - wasTrusted: true, - enabled: true - }, - { - oldKey: oldUntrusted, - newKey: newUntrusted, - command: 'untrusted-user', - reportedOldKey: oldUntrusted, - wasTrusted: false, - enabled: false - } - ] - }) - ).resolves.toEqual({ outcome: 'repaired', repaired: 1 }) - - const batch = requestRpcMock.mock.calls[1] - expect(batch?.[0]).toBe('config/batchWrite') - expect(batch).toBeDefined() - const edits = (batch![1] as { edits: { value: unknown }[] }).edits - expect(edits.some((edit) => edit.value === null)).toBe(true) - expect(edits).toContainEqual( - expect.objectContaining({ - value: expect.objectContaining({ trusted_hash: `sha256:${newTrusted}` }) - }) - ) - expect(edits).toContainEqual(expect.objectContaining({ value: { enabled: false } })) - }) -}) diff --git a/src/main/codex/codex-user-hook-trust-rebase-client.ts b/src/main/codex/codex-user-hook-trust-rebase-client.ts deleted file mode 100644 index 914c989a1616..000000000000 --- a/src/main/codex/codex-user-hook-trust-rebase-client.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { runCodexAppServerSession, type CodexAppServerInvocation } from './codex-app-server-session' -import { normalizeHookTrustKeyForLookup } from './config-toml-trust' - -type CodexHookListing = { - key: string - command: string | null - currentHash: string - trustStatus: string - enabled: boolean -} - -function collectCodexHookListings(result: unknown): CodexHookListing[] { - const data = - result && typeof result === 'object' && Array.isArray((result as { data?: unknown }).data) - ? ((result as { data: unknown[] }).data as { hooks?: unknown }[]) - : [] - const listings: CodexHookListing[] = [] - const seenKeys = new Set() - for (const entry of data) { - const hooks = Array.isArray(entry?.hooks) ? entry.hooks : [] - for (const hook of hooks as Record[]) { - if ( - typeof hook?.key !== 'string' || - typeof hook.currentHash !== 'string' || - typeof hook.trustStatus !== 'string' - ) { - continue - } - // Why: hooks/list repeats user-scope hooks per requested cwd; trust - // rebasing must consider each key once. - if (seenKeys.has(hook.key)) { - continue - } - seenKeys.add(hook.key) - listings.push({ - key: hook.key, - command: typeof hook.command === 'string' ? hook.command : null, - currentHash: hook.currentHash, - trustStatus: hook.trustStatus, - enabled: typeof hook.enabled === 'boolean' ? hook.enabled : true - }) - } - } - return listings -} - -export type CodexUserHookTrustMove = { - oldKey: string - newKey: string - command: string -} - -export type CodexCapturedUserHookTrustMove = CodexUserHookTrustMove & { - reportedOldKey: string - wasTrusted: boolean - enabled: boolean -} - -type CodexUserHookTrustRebaseRequestBase = { - invocation: CodexAppServerInvocation - hooksListCwd: string -} - -export type CodexUserHookTrustInspectRequest = CodexUserHookTrustRebaseRequestBase & { - operation: 'inspect-user-hook-trust' - moves: CodexUserHookTrustMove[] -} - -export type CodexUserHookTrustRepairRequest = CodexUserHookTrustRebaseRequestBase & { - operation: 'repair-user-hook-trust' - moves: CodexCapturedUserHookTrustMove[] -} - -export type CodexUserHookTrustRebaseRequest = - | CodexUserHookTrustInspectRequest - | CodexUserHookTrustRepairRequest - -export type CodexUserHookTrustRebaseResult = - | { outcome: 'inspected'; moves: CodexCapturedUserHookTrustMove[] } - | { outcome: 'repaired'; repaired: number } - -function matchingListings( - listings: readonly CodexHookListing[], - moves: readonly CodexUserHookTrustMove[], - key: 'oldKey' | 'newKey' -): Map { - const expected = new Map( - moves.map((move) => [normalizeHookTrustKeyForLookup(move[key]), move.command]) - ) - return new Map( - listings - .filter( - (listing) => expected.get(normalizeHookTrustKeyForLookup(listing.key)) === listing.command - ) - .map((listing) => [normalizeHookTrustKeyForLookup(listing.key), listing]) - ) -} - -function quotedKeyPath(key: string): string { - const escaped = key.replaceAll('\\', '\\\\').replaceAll('"', '\\"') - return `hooks.state."${escaped}"` -} - -async function inspectUserHookTrust( - request: CodexUserHookTrustInspectRequest -): Promise { - return runCodexAppServerSession(request.invocation, async ({ request: requestRpc }) => { - const result = await requestRpc('hooks/list', { cwds: [request.hooksListCwd] }) - const byOldKey = matchingListings(collectCodexHookListings(result), request.moves, 'oldKey') - if (byOldKey.size !== request.moves.length) { - throw new Error( - `pre-mutation hooks/list reported ${byOldKey.size} of ${request.moves.length} moved user hooks` - ) - } - return { - outcome: 'inspected', - moves: request.moves.map((move) => { - const listing = byOldKey.get(normalizeHookTrustKeyForLookup(move.oldKey))! - return { - ...move, - reportedOldKey: listing.key, - wasTrusted: listing.trustStatus === 'trusted', - enabled: listing.enabled - } - }) - } - }) -} - -async function repairUserHookTrust( - request: CodexUserHookTrustRepairRequest -): Promise { - return runCodexAppServerSession(request.invocation, async ({ request: requestRpc }) => { - const result = await requestRpc('hooks/list', { cwds: [request.hooksListCwd] }) - const byNewKey = matchingListings(collectCodexHookListings(result), request.moves, 'newKey') - if (byNewKey.size !== request.moves.length) { - throw new Error( - `post-mutation hooks/list reported ${byNewKey.size} of ${request.moves.length} moved user hooks` - ) - } - - const keysToClear = new Set([ - ...request.moves.map((move) => move.reportedOldKey), - ...Array.from(byNewKey.values(), (listing) => listing.key) - ]) - const edits: { keyPath: string; value: unknown; mergeStrategy: 'replace' }[] = Array.from( - keysToClear, - (key) => ({ - keyPath: quotedKeyPath(key), - value: null, - mergeStrategy: 'replace' as const - }) - ) - for (const move of request.moves) { - const listing = byNewKey.get(normalizeHookTrustKeyForLookup(move.newKey))! - if (move.wasTrusted) { - edits.push({ - keyPath: quotedKeyPath(listing.key), - value: { - trusted_hash: listing.currentHash, - ...(move.enabled ? {} : { enabled: false }) - }, - mergeStrategy: 'replace' - }) - } else if (!move.enabled) { - edits.push({ - keyPath: quotedKeyPath(listing.key), - value: { enabled: false }, - mergeStrategy: 'replace' - }) - } - } - await requestRpc('config/batchWrite', { edits, reloadUserConfig: true }) - - const verified = await requestRpc('hooks/list', { cwds: [request.hooksListCwd] }) - const verifiedByKey = matchingListings( - collectCodexHookListings(verified), - request.moves, - 'newKey' - ) - const invalid = request.moves.find((move) => { - const listing = verifiedByKey.get(normalizeHookTrustKeyForLookup(move.newKey)) - return ( - !listing || - (listing.trustStatus === 'trusted') !== move.wasTrusted || - listing.enabled !== move.enabled - ) - }) - if (invalid) { - throw new Error(`post-rebase verify failed for moved user hook ${invalid.newKey}`) - } - return { - outcome: 'repaired', - repaired: request.moves.filter((move) => move.wasTrusted).length - } - }) -} - -export function runCodexUserHookTrustRebaseSession( - request: CodexUserHookTrustRebaseRequest -): Promise { - return request.operation === 'inspect-user-hook-trust' - ? inspectUserHookTrust(request) - : repairUserHookTrust(request) -} diff --git a/src/main/codex/codex-user-hook-trust-rebase.test.ts b/src/main/codex/codex-user-hook-trust-rebase.test.ts deleted file mode 100644 index c3e0f760f69f..000000000000 --- a/src/main/codex/codex-user-hook-trust-rebase.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { HookCommandConfig, HookDefinition } from '../agent-hooks/installer-utils' -import type { CodexUserHookTrustRebaseRequest } from './codex-user-hook-trust-rebase-client' - -const resolveCodexCommandMock = vi.hoisted(() => vi.fn(() => process.execPath)) -vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock })) - -import { codexAppServerCapabilityCache } from './codex-app-server-capability-cache' -import { CodexAppServerUnsupportedError } from './codex-app-server-session' -import { - _internals, - getMovedCodexUserHookTrust, - mutateRealHomeHooksPreservingUserTrust -} from './codex-user-hook-trust-rebase' - -let root: string -let hooksPath: string -let configPath: string - -beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'orca-user-hook-rebase-')) - hooksPath = join(root, 'hooks.json') - configPath = join(root, 'config.toml') -}) - -afterEach(() => { - _internals.setSessionRunner(null) - _internals.resetRetryState() - codexAppServerCapabilityCache.clear() - rmSync(root, { recursive: true, force: true }) -}) - -function command(command: string): HookCommandConfig { - return { type: 'command', command } -} - -describe('real-home user hook trust rebasing', () => { - it('writes directly without reading config or spawning Codex when user positions stay stable', async () => { - const user = command('user-hook') - const orca = command('orca-hook') - const before = { Stop: [{ hooks: [user] }] } - const after = { Stop: [{ hooks: [user] }, { hooks: [orca] }] } - let wroteHooks = false - _internals.setSessionRunner(() => { - throw new Error('stable positions must not open an app-server session') - }) - - expect( - await mutateRealHomeHooksPreservingUserTrust({ - sourcePath: hooksPath, - runtimeHomePath: root, - tomlPath: configPath, - beforeHooks: before, - afterHooks: after, - writeHooks: () => { - wroteHooks = true - }, - restoreHooks: () => { - throw new Error('restore must not run') - } - }) - ).toBeNull() - expect(wroteHooks).toBe(true) - expect(existsSync(configPath)).toBe(false) - }) - - it('finds multiple shifted user hooks, including a handler from a mixed group', async () => { - const orca = command('orca-hook') - const first = command('first-user') - const second = command('second-user') - const mixed = command('mixed-user') - const before: Record = { - Stop: [{ hooks: [orca] }, { hooks: [first] }, { hooks: [second] }, { hooks: [orca, mixed] }] - } - const after: Record = { - Stop: [{ hooks: [first] }, { hooks: [second] }, { hooks: [mixed] }] - } - - expect(getMovedCodexUserHookTrust(hooksPath, before, after)).toEqual([ - expect.objectContaining({ - command: 'first-user', - oldKey: expect.stringContaining(':1:0'), - newKey: expect.stringContaining(':0:0') - }), - expect.objectContaining({ - command: 'second-user', - oldKey: expect.stringContaining(':2:0'), - newKey: expect.stringContaining(':1:0') - }), - expect.objectContaining({ - command: 'mixed-user', - oldKey: expect.stringContaining(':3:1'), - newKey: expect.stringContaining(':2:0') - }) - ]) - }) - - it('carries only previously trusted states into the repair request', async () => { - const orca = command('orca-hook') - const trusted = command('trusted-user') - const untrusted = command('untrusted-user') - const before = { Stop: [{ hooks: [orca] }, { hooks: [trusted] }, { hooks: [untrusted] }] } - const after = { Stop: [{ hooks: [trusted] }, { hooks: [untrusted] }] } - writeFileSync(hooksPath, `${JSON.stringify({ hooks: before }, null, 2)}\n`) - writeFileSync(configPath, '# original config\n') - const requests: CodexUserHookTrustRebaseRequest[] = [] - _internals.setSessionRunner(async (request) => { - requests.push(request) - if (request.operation === 'inspect-user-hook-trust') { - return { - outcome: 'inspected', - moves: request.moves.map((move) => ({ - ...move, - reportedOldKey: move.oldKey, - wasTrusted: move.command === 'trusted-user', - enabled: move.command !== 'untrusted-user' - })) - } - } - return { outcome: 'repaired', repaired: 1 } - }) - - await mutateRealHomeHooksPreservingUserTrust({ - sourcePath: hooksPath, - runtimeHomePath: root, - tomlPath: configPath, - beforeHooks: before, - afterHooks: after, - writeHooks: () => writeFileSync(hooksPath, `${JSON.stringify({ hooks: after }, null, 2)}\n`), - restoreHooks: () => { - throw new Error('restore must not run') - } - }) - - expect(requests).toHaveLength(2) - expect(requests[0]?.invocation.envToDelete).toContain('CODEX_HOME') - const repair = requests[1] - expect(repair?.operation).toBe('repair-user-hook-trust') - if (repair?.operation === 'repair-user-hook-trust') { - expect(repair.moves).toEqual([ - expect.objectContaining({ command: 'trusted-user', wasTrusted: true, enabled: true }), - expect.objectContaining({ command: 'untrusted-user', wasTrusted: false, enabled: false }) - ]) - } - }) - - it('marks the host unsupported and skips further codex sessions', async () => { - const orca = command('orca-hook') - const user = command('user-hook') - const before = { Stop: [{ hooks: [orca] }, { hooks: [user] }] } - const after = { Stop: [{ hooks: [user] }] } - writeFileSync(configPath, '# config\n') - let sessions = 0 - _internals.setSessionRunner(async () => { - sessions += 1 - throw new CodexAppServerUnsupportedError('unrecognized subcommand app-server') - }) - const args = { - sourcePath: hooksPath, - runtimeHomePath: root, - tomlPath: configPath, - beforeHooks: before, - afterHooks: after, - writeHooks: () => { - throw new Error('write must not run') - }, - restoreHooks: () => { - throw new Error('restore must not run') - } - } - - await expect(mutateRealHomeHooksPreservingUserTrust(args)).rejects.toThrow( - 'unrecognized subcommand' - ) - await expect(mutateRealHomeHooksPreservingUserTrust(args)).rejects.toThrow('marked unsupported') - expect(sessions).toBe(1) - expect(codexAppServerCapabilityCache.shouldTry('native')).toBe(false) - }) - - it('cools down after a transient session failure instead of retrying every launch prep', async () => { - const orca = command('orca-hook') - const user = command('user-hook') - const before = { Stop: [{ hooks: [orca] }, { hooks: [user] }] } - const after = { Stop: [{ hooks: [user] }] } - writeFileSync(configPath, '# config\n') - let sessions = 0 - _internals.setSessionRunner(async () => { - sessions += 1 - throw new Error('pre-mutation hooks/list reported 0 of 1 moved user hooks') - }) - const args = { - sourcePath: hooksPath, - runtimeHomePath: root, - tomlPath: configPath, - beforeHooks: before, - afterHooks: after, - writeHooks: () => { - throw new Error('write must not run') - }, - restoreHooks: () => { - throw new Error('restore must not run') - } - } - - await expect(mutateRealHomeHooksPreservingUserTrust(args)).rejects.toThrow( - '0 of 1 moved user hooks' - ) - await expect(mutateRealHomeHooksPreservingUserTrust(args)).rejects.toThrow('cooling down') - expect(sessions).toBe(1) - // Why: a transient failure must not poison the shared capability signal. - expect(codexAppServerCapabilityCache.shouldTry('native')).toBe(true) - }) - - it('restores both files byte-exactly when post-mutation repair fails', async () => { - const orca = command('orca-hook') - const user = command('user-hook') - const before = { Stop: [{ hooks: [orca] }, { hooks: [user] }] } - const after = { Stop: [{ hooks: [user] }] } - const originalHooks = - '{ "hooks": { "Stop": [{"hooks":[{"type":"command","command":"orca-hook"}]},{"hooks":[{"type":"command","command":"user-hook"}]}] } }\r\n' - const originalConfig = '# user formatting\r\nmodel = "x"\r\n' - writeFileSync(hooksPath, originalHooks) - writeFileSync(configPath, originalConfig) - _internals.setSessionRunner(async (request) => { - if (request.operation === 'inspect-user-hook-trust') { - return { - outcome: 'inspected', - moves: request.moves.map((move) => ({ - ...move, - reportedOldKey: move.oldKey, - wasTrusted: true, - enabled: true - })) - } - } - writeFileSync(configPath, '[hooks.state."partial"]\ntrusted_hash = "bad"\n') - throw new Error('repair transport failed') - }) - - await expect( - mutateRealHomeHooksPreservingUserTrust({ - sourcePath: hooksPath, - runtimeHomePath: root, - tomlPath: configPath, - beforeHooks: before, - afterHooks: after, - writeHooks: () => writeFileSync(hooksPath, `${JSON.stringify({ hooks: after })}\n`), - restoreHooks: () => writeFileSync(hooksPath, originalHooks) - }) - ).rejects.toThrow('repair transport failed') - expect(readFileSync(hooksPath, 'utf-8')).toBe(originalHooks) - expect(readFileSync(configPath, 'utf-8')).toBe(originalConfig) - }) -}) diff --git a/src/main/codex/codex-user-hook-trust-rebase.ts b/src/main/codex/codex-user-hook-trust-rebase.ts deleted file mode 100644 index 67a69afce6ea..000000000000 --- a/src/main/codex/codex-user-hook-trust-rebase.ts +++ /dev/null @@ -1,222 +0,0 @@ -import type { HookCommandConfig, HookDefinition } from '../agent-hooks/installer-utils' -import { - codexAppServerCapabilityCache, - getCodexAppServerHostKey, - type CodexAppServerHostKey -} from './codex-app-server-capability-cache' -import { runCodexUserHookTrustRebaseSession } from './codex-user-hook-trust-rebase-client' -import { isCodexAppServerUnsupportedError } from './codex-app-server-session' -import { CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS } from './codex-hook-trust-grant' -import { createCodexHookTrustEntry } from './codex-hook-identity' -import { resolveCodexTrustGrantHost } from './codex-trust-grant-host' -import { - captureCodexTrustConfig, - restoreCodexTrustConfig, - type CodexTrustConfigSnapshot -} from './codex-trust-config-rollback' -import { runExclusivelyForCodexTrustConfig } from './codex-trust-config-mutation-queue' -import { computeTrustKey, type CodexTrustEntry } from './config-toml-trust' -import type { - CodexUserHookTrustRebaseRequest, - CodexUserHookTrustRebaseResult, - CodexUserHookTrustMove -} from './codex-user-hook-trust-rebase-client' - -type HooksByEvent = Record - -type RebaseSessionRunner = ( - request: CodexUserHookTrustRebaseRequest -) => Promise - -// Why (#16441): the session runs in-process; forking it through spawnSync -// froze the main thread for the whole app-server deadline on every install. -let runSession: RebaseSessionRunner = runCodexUserHookTrustRebaseSession - -// Why: launch prep re-runs the callers on every pane spawn. A host stuck -// without a usable rebase lane (old CLI, unmatched keys) must not pay a codex -// session each time — bound retries like the grant lane does. -const rebaseRetryAfterByHost = new Map() - -function rememberRebaseSessionFailure(hostKey: CodexAppServerHostKey, error: unknown): void { - if (isCodexAppServerUnsupportedError(error)) { - codexAppServerCapabilityCache.rememberUnsupported(hostKey) - return - } - rebaseRetryAfterByHost.set(hostKey, Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS) -} - -function entriesByHookObject( - sourcePath: string, - hooksByEvent: HooksByEvent -): Map { - const result = new Map() - for (const [eventName, definitions] of Object.entries(hooksByEvent)) { - if (!Array.isArray(definitions)) { - continue - } - definitions.forEach((definition, groupIndex) => { - if (!Array.isArray(definition.hooks)) { - return - } - definition.hooks.forEach((hook, handlerIndex) => { - const entry = createCodexHookTrustEntry( - sourcePath, - eventName, - groupIndex, - handlerIndex, - definition, - hook - ) - if (entry) { - result.set(hook, entry) - } - }) - }) - } - return result -} - -export function getMovedCodexUserHookTrust( - sourcePath: string, - beforeHooks: HooksByEvent, - afterHooks: HooksByEvent -): CodexUserHookTrustMove[] { - const before = entriesByHookObject(sourcePath, beforeHooks) - const after = entriesByHookObject(sourcePath, afterHooks) - const moves: CodexUserHookTrustMove[] = [] - for (const [hook, oldEntry] of before) { - const newEntry = after.get(hook) - if (!newEntry) { - continue - } - const oldKey = computeTrustKey(oldEntry) - const newKey = computeTrustKey(newEntry) - if (oldKey !== newKey) { - moves.push({ oldKey, newKey, command: oldEntry.command }) - } - } - return moves -} - -function rollbackMutation( - restoreHooks: () => void, - tomlPath: string, - snapshot: CodexTrustConfigSnapshot, - originalError: unknown -): never { - const rollbackErrors: unknown[] = [] - try { - restoreHooks() - } catch (error) { - rollbackErrors.push(error) - } - try { - restoreCodexTrustConfig(tomlPath, snapshot) - } catch (error) { - rollbackErrors.push(error) - } - if (rollbackErrors.length > 0) { - throw new AggregateError( - [originalError, ...rollbackErrors], - 'Failed to rebase moved user hook trust and restore the original files' - ) - } - throw originalError -} - -export function mutateRealHomeHooksPreservingUserTrust(args: { - sourcePath: string - runtimeHomePath: string - tomlPath: string - beforeHooks: HooksByEvent - afterHooks: HooksByEvent - writeHooks: () => void - restoreHooks: () => void -}): Promise { - const moves = getMovedCodexUserHookTrust(args.sourcePath, args.beforeHooks, args.afterHooks) - if (moves.length === 0) { - args.writeHooks() - return Promise.resolve(null) - } - // Why: capture/mutate/restore on one config.toml is not reentrant. - return runExclusivelyForCodexTrustConfig(args.tomlPath, () => rebaseMovedUserTrust(args, moves)) -} - -async function rebaseMovedUserTrust( - args: { - runtimeHomePath: string - tomlPath: string - writeHooks: () => void - restoreHooks: () => void - }, - moves: CodexUserHookTrustMove[] -): Promise { - const hostKey = getCodexAppServerHostKey({ kind: 'native' }) - if (!codexAppServerCapabilityCache.shouldTry(hostKey)) { - throw new Error('codex app-server is marked unsupported on this host; trust rebase skipped') - } - const retryAfterMs = rebaseRetryAfterByHost.get(hostKey) - if (retryAfterMs !== undefined) { - if (Date.now() < retryAfterMs) { - throw new Error('Codex user hook trust rebase is cooling down after a recent failure') - } - rebaseRetryAfterByHost.delete(hostKey) - } - const snapshot = captureCodexTrustConfig(args.tomlPath) - - const baseRequest = (await resolveCodexTrustGrantHost({ kind: 'native' })).buildRequest({ - runtimeHomePath: args.runtimeHomePath, - managedCommand: '', - expectedTrustKeys: [], - useDefaultCodexHome: true - }) - // Why: inspection happens before the write, so an unavailable RPC aborts - // without shifting a user's positional trust key. - let inspected: CodexUserHookTrustRebaseResult - try { - inspected = await runSession({ - operation: 'inspect-user-hook-trust', - invocation: baseRequest.invocation, - hooksListCwd: baseRequest.hooksListCwd, - moves - }) - } catch (error) { - rememberRebaseSessionFailure(hostKey, error) - throw error - } - codexAppServerCapabilityCache.rememberSupported(hostKey) - if (inspected.outcome !== 'inspected') { - throw new Error('Unexpected Codex user hook trust inspection result') - } - - let hooksWritten = false - try { - args.writeHooks() - hooksWritten = true - const repaired = await runSession({ - operation: 'repair-user-hook-trust', - invocation: baseRequest.invocation, - hooksListCwd: baseRequest.hooksListCwd, - moves: inspected.moves - }) - if (repaired.outcome !== 'repaired') { - throw new Error('Unexpected Codex user hook trust repair result') - } - return snapshot - } catch (error) { - rememberRebaseSessionFailure(hostKey, error) - if (hooksWritten) { - return rollbackMutation(args.restoreHooks, args.tomlPath, snapshot, error) - } - throw error - } -} - -export const _internals = { - setSessionRunner(runner: RebaseSessionRunner | null): void { - runSession = runner ?? runCodexUserHookTrustRebaseSession - }, - resetRetryState(): void { - rebaseRetryAfterByHost.clear() - } -} diff --git a/src/main/codex/config-toml-hook-trust-edit.ts b/src/main/codex/config-toml-hook-trust-edit.ts index bd739f0ec3ff..e7858dd694cb 100644 --- a/src/main/codex/config-toml-hook-trust-edit.ts +++ b/src/main/codex/config-toml-hook-trust-edit.ts @@ -9,10 +9,11 @@ import { } from './codex-trust-identity' import { ensureHooksStateParentTable, + findAllHookTrustBlocks, findHookTrustBlockRanges, type HookTrustBlockRange } from './config-toml-hook-trust-blocks' -import { escapeTomlBasicString } from './config-toml-syntax' +import { CODEX_HOOK_TRUST_KEY, escapeTomlBasicString } from './config-toml-syntax' import { repairOrcaDuplicateTrustTables } from './config-toml-project-duplicate-repair' export function upsertHookTrustContent( @@ -51,6 +52,52 @@ export function removeHookTrustContent(content: string, keys: readonly string[]) return updated + content.slice(cursor) } +/** + * Moves each hook's trust block to the hook's new key, body bytes unchanged: + * only what Codex wrote moves, and no hash is ever computed. Codex hashes a + * hook's content, not its path or position, so the moved block stays exactly + * as valid as before; a hook with no block keeps none. If any stored key has + * an unknown shape, nothing moves and Codex asks the user to review instead. + */ +export function moveHookTrustContent( + existingContent: string, + moves: readonly { oldKey: string; newKey: string }[] +): string { + const content = stripLeadingBom(existingContent) + if (findAllHookTrustBlocks(content).some(({ key }) => !CODEX_HOOK_TRUST_KEY.test(key))) { + return existingContent + } + const bodies = moves.flatMap(({ oldKey, newKey }) => { + const [range] = findHookTrustBlockRanges( + content, + new Set([normalizeCodexHookTrustLookupKey(oldKey)]) + ) + return range ? [{ newKey, body: content.slice(range.contentStart, range.end).trimEnd() }] : [] + }) + let updated = removeHookTrustContent(content, [ + ...moves.map(({ oldKey }) => oldKey), + ...moves.map(({ newKey }) => newKey) + ]) + if (bodies.length === 0) { + return updated + } + if ( + bodies.some(({ newKey }) => + usesWindowsCodexPathSeparators(parseCodexTrustKey(newKey)?.sourcePath ?? '') + ) + ) { + updated = ensureHooksStateParentTable(updated) + } + const blocks = bodies.flatMap(({ newKey, body }) => + getTrustKeyWriteVariants(newKey).map( + (key) => `[hooks.state.${formatHookStateTableKey(key)}]${body ? `\n${body}` : ''}` + ) + ) + const separator = + updated.length === 0 || updated.endsWith('\n\n') ? '' : updated.endsWith('\n') ? '\n' : '\n\n' + return `${updated}${separator}${blocks.join('\n\n')}\n` +} + function upsertTrustBlocks( content: string, keys: readonly string[], diff --git a/src/main/codex/config-toml-runtime-owned-sections.ts b/src/main/codex/config-toml-runtime-owned-sections.ts index 6e07cca99cd9..cf60c3fe95be 100644 --- a/src/main/codex/config-toml-runtime-owned-sections.ts +++ b/src/main/codex/config-toml-runtime-owned-sections.ts @@ -7,7 +7,7 @@ import { } from './config-toml-line-scan' import { parseTomlTableHeaderPath } from './config-toml-key-path' import { findProjectTrustLevelEntries } from './config-toml-project-trust-level' -import { parseHookStateTomlHeaderKey } from './config-toml-syntax' +import { CODEX_HOOK_TRUST_KEY, parseHookStateTomlHeaderKey } from './config-toml-syntax' import { codexHookSourcePathsEqual, getCodexExplicitHomeHookSourcePath, @@ -106,9 +106,6 @@ export function isRuntimeHookTrustTomlSection(header: string): boolean { return !!table && !table.isArray && table.segments[0] === 'hooks' && table.segments[1] === 'state' } -// Why: Codex's `{source}:{event}:{group}:{handler}` for any label, incl. session_end/interrupt. -const CODEX_HOOK_TRUST_KEY = /^(.+):[a-z_]+:(?:0|[1-9]\d*):(?:0|[1-9]\d*)$/ - // Why: user-layer keys name the home's own hooks.json/config.toml; plugin/project keys don't. export function classifyHookTrustKey(key: string, homeDir: string): 'home-scoped' | 'shared' { const sourcePath = CODEX_HOOK_TRUST_KEY.exec(key)?.[1] diff --git a/src/main/codex/config-toml-syntax.ts b/src/main/codex/config-toml-syntax.ts index a9f9ed5efc4c..61c23b0c4e0a 100644 --- a/src/main/codex/config-toml-syntax.ts +++ b/src/main/codex/config-toml-syntax.ts @@ -17,6 +17,9 @@ export function escapeTomlBasicString(value: string): string { .replaceAll('\t', '\\t') } +// Why: Codex's hook_key is `{source}:{event}:{group}:{handler}` for any label (unchanged 0.141-0.158). +export const CODEX_HOOK_TRUST_KEY = /^(.+):[a-z_]+:(?:0|[1-9]\d*):(?:0|[1-9]\d*)$/ + export function parseHookStateTomlHeaderKey(line: string): string | null { return parseTomlTableHeaderLeafKey(line, ['hooks', 'state']) } diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index 8b1943c058e2..d106a1920f2a 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -11,7 +11,11 @@ import { parseCodexTrustKey } from './codex-trust-identity' import { writeTomlConfigAtomically } from './config-toml-atomic-write' -import { removeHookTrustContent, upsertHookTrustContent } from './config-toml-hook-trust-edit' +import { + moveHookTrustContent, + removeHookTrustContent, + upsertHookTrustContent +} from './config-toml-hook-trust-edit' import { CodexHookTrustEntryMap, readHookTrustContent } from './config-toml-hook-trust-read' import { upsertProjectTrustContent } from './config-toml-project-trust' import { escapeTomlBasicString, parseProjectTomlHeaderPath } from './config-toml-syntax' @@ -110,6 +114,21 @@ export function upsertHookTrustEntries( } } +/** Moves hook trust blocks to new keys verbatim; see moveHookTrustContent. */ +export function moveHookTrustEntries( + configPath: string, + moves: readonly { oldKey: string; newKey: string }[] +): void { + if (moves.length === 0 || !existsSync(configPath)) { + return + } + const existing = readTomlForMutation(configPath) + const updated = moveHookTrustContent(existing, moves) + if (updated !== existing) { + writeConfigAtomically(configPath, updated) + } +} + export function upsertHookTrustEntriesInContent( existingContent: string, entries: readonly CodexTrustEntry[] diff --git a/src/main/codex/hook-service-legacy-cleanup.test.ts b/src/main/codex/hook-service-legacy-cleanup.test.ts index f2b941944c03..dde70016ee6a 100644 --- a/src/main/codex/hook-service-legacy-cleanup.test.ts +++ b/src/main/codex/hook-service-legacy-cleanup.test.ts @@ -43,17 +43,75 @@ const LEGACY_ORCA_PROFILE_LINES = [ '' ] +// Why a retired form: the sweep may remove only commands no current build writes +// (#1536's exec-guarded form on POSIX, a per-userData bare path on Windows). function legacyManagedHookCommand(): string { - const legacyScriptPath = join( + if (process.platform === 'win32') { + return join(homes.userDataDir, 'agent-hooks', 'codex-hook.cmd') + } + const quoted = `'${join(homes.tmpHome, '.orca', 'agent-hooks', 'codex-hook.sh')}'` + return `if [ -x ${quoted} ]; then /bin/sh ${quoted}; fi` +} + +function currentManagedHookCommand(): string { + const scriptPath = join( homes.tmpHome, '.orca', 'agent-hooks', process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh' ) - return process.platform === 'win32' ? legacyScriptPath : wrapPosixHookCommand(legacyScriptPath) + return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath) } describe('CodexHookService', () => { + // Why: every Orca on this HOME writes the same current entry; an install for a + // managed account (launch prep for any pane) used to strip it and its trust. + it('keeps the shared current entry and its trust while removing a retired form', async () => { + const systemCodexHome = join(homes.tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + const currentCommand = currentManagedHookCommand() + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + systemHooksPath, + `${JSON.stringify( + { + hooks: { + Stop: [ + { hooks: [{ type: 'command', command: 'user-hook' }] }, + { hooks: [{ type: 'command', command: currentCommand, timeout: 10 }] } + ], + SessionStart: [{ hooks: [{ type: 'command', command: legacyManagedHookCommand() }] }] + } + }, + null, + 2 + )}\n`, + 'utf-8' + ) + const currentTrust = { + sourcePath: systemHooksPath, + eventLabel: 'stop' as const, + groupIndex: 1, + handlerIndex: 0, + command: currentCommand, + timeoutSec: 10 + } + writeFileSync( + join(systemCodexHome, 'config.toml'), + upsertHookTrustEntriesInContent('model = "system-model"\n', [currentTrust]), + 'utf-8' + ) + + expect((await new CodexHookService().install()).state).toBe('installed') + + const systemHooks = JSON.parse(readFileSync(systemHooksPath, 'utf-8')) as { + hooks: Record + } + expect(systemHooks.hooks.Stop?.[1]?.hooks?.[0]?.command).toBe(currentCommand) + expect(systemHooks.hooks.SessionStart).toBeUndefined() + expect(readFileSync(join(systemCodexHome, 'config.toml'), 'utf-8')).toContain(':stop:1:0') + }) + it('removes legacy Orca-managed hooks from system ~/.codex during install', async () => { const systemCodexHome = join(homes.tmpHome, '.codex') const systemHooksPath = join(systemCodexHome, 'hooks.json') diff --git a/src/main/codex/hook-service-managed-install.test.ts b/src/main/codex/hook-service-managed-install.test.ts index caa67752a7f5..5fb41a14e35a 100644 --- a/src/main/codex/hook-service-managed-install.test.ts +++ b/src/main/codex/hook-service-managed-install.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { removeTreeSync } from '../../shared/windows-transient-lock-removal' import { homedir, tmpdir } from 'node:os' import type * as Os from 'node:os' import { join } from 'node:path' @@ -28,7 +29,7 @@ vi.mock('os', async (importOriginal) => { }) import { CodexHookService } from './hook-service' -import { buildWindowsHookPowerShellCommand } from '../agent-hooks/installer-utils' +import { getManagedCommand } from './codex-hook-definition' import { runExclusivelyForCodexTrustConfig } from './codex-trust-config-mutation-queue' const homes = setupCodexHookHomes(homedirMock, getPathMock) @@ -182,7 +183,7 @@ describe('CodexHookService', () => { expect(Object.keys(hooksConfig)).toEqual(['hooks']) }) - // #6078: the existing PowerShell host must still quote spaced profile paths. + // #6078: a spaced profile path must still reach the script through Windows' own cmd.exe. it.skipIf(process.platform !== 'win32')( 'wraps the managed hook command when the profile path contains a space (#6078)', async () => { @@ -204,13 +205,11 @@ describe('CodexHookService', () => { for (const eventName of localManagedCodexEvents()) { const command = hooksConfig.hooks[eventName]?.[0]?.hooks?.[0]?.command expect(command).toBe( - buildWindowsHookPowerShellCommand( - join(homedir(), '.orca', 'agent-hooks', 'codex-hook.cmd') - ) + getManagedCommand(join(homedir(), '.orca', 'agent-hooks', 'codex-hook.cmd')) ) } } finally { - rmSync(spaceHome, { recursive: true, force: true }) + removeTreeSync(spaceHome) } } ) @@ -237,13 +236,11 @@ describe('CodexHookService', () => { for (const eventName of localManagedCodexEvents()) { const command = hooksConfig.hooks[eventName]?.[0]?.hooks?.[0]?.command expect(command).toBe( - buildWindowsHookPowerShellCommand( - join(homedir(), '.orca', 'agent-hooks', 'codex-hook.cmd') - ) + getManagedCommand(join(homedir(), '.orca', 'agent-hooks', 'codex-hook.cmd')) ) } } finally { - rmSync(metacharHome, { recursive: true, force: true }) + removeTreeSync(metacharHome) } } ) @@ -268,12 +265,10 @@ describe('CodexHookService', () => { const cmdSafe = /^[A-Za-z0-9_.:\\~-]+$/.test(join(homes.tmpHome, '.orca', 'agent-hooks')) if (cmdSafe) { expect(command).not.toMatch(/powershell/i) - expect(command).toMatch(/\\agent-hooks\\codex-hook\.cmd$/) + expect(command).toMatch(/\/agent-hooks\/codex-hook\.cmd$/) } else { expect(command).toBe( - buildWindowsHookPowerShellCommand( - join(homedir(), '.orca', 'agent-hooks', 'codex-hook.cmd') - ) + getManagedCommand(join(homedir(), '.orca', 'agent-hooks', 'codex-hook.cmd')) ) } } @@ -416,8 +411,8 @@ describe('CodexHookService', () => { expect(readFileSync(systemHooksPath, 'utf-8')).toBe(existingSystemHooks) } finally { process.env.ORCA_USER_DATA_PATH = homes.userDataDir - rmSync(devUserDataDir, { recursive: true, force: true }) - rmSync(prodUserDataDir, { recursive: true, force: true }) + removeTreeSync(devUserDataDir) + removeTreeSync(prodUserDataDir) } }) }) diff --git a/src/main/codex/hook-service-test-harness.ts b/src/main/codex/hook-service-test-harness.ts index f6a450a263dc..03cba32d316c 100644 --- a/src/main/codex/hook-service-test-harness.ts +++ b/src/main/codex/hook-service-test-harness.ts @@ -8,9 +8,8 @@ import { normalizeCodexHookSourcePath } from './config-toml-trust' import { _internals as grantInternals } from './codex-hook-trust-grant' -import { _internals as rebaseInternals } from './codex-user-hook-trust-rebase' -// Why (#16441): the grant/rebase sessions now run in-process instead of in a +// Why (#16441): the grant session now runs in-process instead of in a // forked bundle that never existed under vitest. Without this stub these // suites spawn the developer's real `codex app-server`, so they pass in CI // (no codex installed) and fail on any machine that has one. Stand in for the @@ -28,14 +27,11 @@ export type CodexHookHomes = { /** Applies the stub above; for suites that build their own temp homes. */ export function stubCodexTrustSessionsForTests(): void { grantInternals.setGrantSessionRunner(stubMissingCodexBinary) - rebaseInternals.setSessionRunner(stubMissingCodexBinary) } export function restoreCodexTrustSessionsForTests(): void { grantInternals.setGrantSessionRunner(null) grantInternals.resetDiagnostics() - rebaseInternals.setSessionRunner(null) - rebaseInternals.resetRetryState() } export function setupCodexHookHomes( diff --git a/src/main/codex/hook-service-trust-grant.test.ts b/src/main/codex/hook-service-trust-grant.test.ts index 1b4ab0cd7ac3..a24ebf544265 100644 --- a/src/main/codex/hook-service-trust-grant.test.ts +++ b/src/main/codex/hook-service-trust-grant.test.ts @@ -33,7 +33,6 @@ import { } from './codex-trust-grant-ledger' import type { CodexHookTrustGrantRequest } from './codex-app-server-client' import { getCodexHookTrustSignature } from './codex-hook-identity' -import { _internals as rebaseInternals } from './codex-user-hook-trust-rebase' const { getPathMock, homedirMock, resolveCodexCommandMock } = vi.hoisted(() => ({ getPathMock: vi.fn<(name: string) => string>(), @@ -75,7 +74,6 @@ beforeEach(() => { }) afterEach(() => { - rebaseInternals.setSessionRunner(null) trustGrantInternals.setGrantSessionRunner(null) trustGrantInternals.resetDiagnostics() codexAppServerCapabilityCache.clear() @@ -193,7 +191,7 @@ describe('CodexHookService app-server trust grant lane', () => { expect(readFileSync(join(managedHome, 'config.toml'))).toEqual(firstToml) }) - it('retries ledger-proven real-home trust cleanup after the hook is already gone', async () => { + it('retries ledger-proven real-home trust cleanup on opt-out after the hook is already gone', async () => { prepareSystemHome() const systemHome = join(tmpHome, '.codex') const hooksPath = join(systemHome, 'hooks.json') @@ -223,7 +221,7 @@ describe('CodexHookService app-server trust grant lane', () => { }) installCodexLikeGrantRunner() - expect((await new CodexHookService().install()).state).toBe('installed') + await new CodexHookService().remove() expect(readHookTrustEntries(configPath).has(trustKey)).toBe(false) expect(readCodexTrustGrantLedgerHome(systemHome)).toBeNull() @@ -231,7 +229,7 @@ describe('CodexHookService app-server trust grant lane', () => { // Why: ordinary Windows CI tokens cannot create file symlinks without Developer Mode. it.skipIf(process.platform === 'win32')( - 'keeps a real-home symlink and rebases later user trust during flag-off cleanup', + 'keeps a real-home symlink and moves later user trust during an explicit opt-out', async () => { prepareSystemHome() const systemHome = join(tmpHome, '.codex') @@ -255,36 +253,19 @@ describe('CodexHookService app-server trust grant lane', () => { )}\n` ) symlinkSync(targetPath, hooksPath) - const operations: string[] = [] - rebaseInternals.setSessionRunner(async (request) => { - operations.push(request.operation) - if (request.operation === 'inspect-user-hook-trust') { - return { - outcome: 'inspected', - moves: request.moves.map((move) => ({ - ...move, - reportedOldKey: move.oldKey, - wasTrusted: true, - enabled: true - })) - } - } - return { outcome: 'repaired', repaired: 1 } - }) installCodexLikeGrantRunner() - expect((await new CodexHookService().install()).state).toBe('installed') + await new CodexHookService().remove() expect(lstatSync(hooksPath).isSymbolicLink()).toBe(true) expect(JSON.parse(readFileSync(targetPath, 'utf-8')).hooks.Stop).toEqual([ { hooks: [userHook] } ]) - expect(operations).toEqual(['inspect-user-hook-trust', 'repair-user-hook-trust']) } ) it.skipIf(process.platform === 'win32')( - 'preserves restrictive real-home hooks permissions during flag-off cleanup', + 'preserves restrictive real-home hooks permissions during an explicit opt-out', async () => { prepareSystemHome() const hooksPath = join(tmpHome, '.codex', 'hooks.json') @@ -296,7 +277,7 @@ describe('CodexHookService app-server trust grant lane', () => { chmodSync(hooksPath, 0o600) installCodexLikeGrantRunner() - expect((await new CodexHookService().install()).state).toBe('installed') + await new CodexHookService().remove() expect(statSync(hooksPath).mode & 0o777).toBe(0o600) } @@ -379,29 +360,30 @@ describe('CodexHookService app-server trust grant lane', () => { expect(resolveCodexCommandMock).not.toHaveBeenCalled() }) - it('restores exact config bytes before fallback after a mutating RPC failure', async () => { + // Why: without a restore, the managed fallback still settles Orca's trust, and + // a save made during the failed session survives. + it('settles managed trust through the fallback after a mutating RPC failure', async () => { prepareSystemHome() const service = new CodexHookService() process.env.ORCA_DISABLE_CODEX_TRUST_RPC = '1' expect((await service.install()).state).toBe('installed') const managedHome = join(userDataDir, 'codex-runtime-home', 'home') - const baseline = readFileSync(join(managedHome, 'config.toml')) + const baselineTrust = readHookTrustEntries(join(managedHome, 'config.toml')) delete process.env.ORCA_DISABLE_CODEX_TRUST_RPC - rmSync(managedHome, { recursive: true, force: true }) trustGrantInternals.resetDiagnostics() const runner = vi.fn(async (request: CodexHookTrustGrantRequest) => { - const codexHome = request.invocation.env?.CODEX_HOME - writeFileSync( - join(codexHome!, 'config.toml'), - '[hooks.state."rpc-partial"]\ntrusted_hash = "sha256:changed"\n' - ) + const tomlPath = join(request.invocation.env!.CODEX_HOME!, 'config.toml') + writeFileSync(tomlPath, `model = "saved-during-session"\n${readFileSync(tomlPath, 'utf8')}`) throw new Error('transport failed after config/batchWrite') }) trustGrantInternals.setGrantSessionRunner(runner) expect((await service.install()).state).toBe('installed') expect(runner).toHaveBeenCalledTimes(1) - expect(readFileSync(join(managedHome, 'config.toml'))).toEqual(baseline) + const configPath = join(managedHome, 'config.toml') + expect(readFileSync(configPath, 'utf8')).toContain('model = "saved-during-session"') + expect(baselineTrust.size).toBeGreaterThan(0) + expect(new Map(readHookTrustEntries(configPath))).toEqual(new Map(baselineTrust)) }) }) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index dbd6a5159227..6e8f989112ca 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -10,7 +10,6 @@ import { export type { CodexManagedHookInstallMaterial } from './codex-hook-definition' export { getCodexManagedHookInstallMaterial } from './codex-hook-definition' -export { setSystemCodexHomeHookSweepSuppressed } from './codex-hook-legacy-cleanup' export { CodexHookService } export { createCodexWslRuntimeHookInstallPlan, diff --git a/src/main/codex/managed-home-shell-preflight.test.ts b/src/main/codex/managed-home-shell-preflight.test.ts deleted file mode 100644 index 31a98a30b2f5..000000000000 --- a/src/main/codex/managed-home-shell-preflight.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' - -const { wslRealpathMock } = vi.hoisted(() => ({ - wslRealpathMock: vi.fn(async (path: string) => path) -})) - -vi.mock('node:fs/promises', () => ({ realpath: wslRealpathMock })) - -import { - prepareManagedCodexHomeBeforeShellLaunch, - resolveManagedCodexShellPreflightHome -} from './managed-home-shell-preflight' -import { - prepareManagedWslCodexHomeBeforeShellLaunch, - resolveManagedWslCodexShellPreflightTarget -} from './managed-wsl-home-shell-preflight' -import { - _internals as managedWslHomeRegistryInternals, - recordManagedWslCodexHome -} from './managed-wsl-codex-home-registry' - -const roots: string[] = [] - -function makeRoot(): string { - const root = mkdtempSync(join(tmpdir(), 'orca-codex-shell-preflight-')) - roots.push(root) - return root -} - -afterEach(() => { - wslRealpathMock.mockClear() - managedWslHomeRegistryInternals.clearRecordedManagedWslCodexHomes() - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -describe('managed Codex shell preflight', () => { - it('accepts the Orca shared runtime home', () => { - const userDataPath = makeRoot() - const home = join(userDataPath, 'codex-runtime-home', 'home') - mkdirSync(home, { recursive: true }) - - expect( - resolveManagedCodexShellPreflightHome( - { CODEX_HOME: home, ORCA_CODEX_HOME: home }, - userDataPath - ) - ).toBe(home) - }) - - it('accepts a marker-proven account home and installs only while hooks are enabled', async () => { - const userDataPath = makeRoot() - const home = join(userDataPath, 'codex-accounts', 'account-1', 'home') - mkdirSync(home, { recursive: true }) - writeFileSync(join(home, '.orca-managed-home'), 'account-1\n') - const install = vi.fn(() => ({ - agent: 'codex' as const, - state: 'installed' as const, - configPath: join(home, 'hooks.json'), - managedHooksPresent: true, - detail: null - })) - const env = { CODEX_HOME: home, ORCA_CODEX_HOME: home } - - expect( - await prepareManagedCodexHomeBeforeShellLaunch({ - userDataPath, - hooksEnabled: true, - env, - install - }) - ).toMatchObject({ state: 'installed' }) - expect(install).toHaveBeenCalledWith(home) - - expect( - await prepareManagedCodexHomeBeforeShellLaunch({ - userDataPath, - hooksEnabled: false, - env, - install - }) - ).toBeNull() - expect(install).toHaveBeenCalledTimes(1) - }) - - it('rejects user-owned homes and mismatched shell routing markers', () => { - const userDataPath = makeRoot() - const userHome = join(userDataPath, 'user-codex-home') - const managedHome = join(userDataPath, 'codex-runtime-home', 'home') - mkdirSync(userHome, { recursive: true }) - mkdirSync(managedHome, { recursive: true }) - - expect( - resolveManagedCodexShellPreflightHome( - { CODEX_HOME: userHome, ORCA_CODEX_HOME: userHome }, - userDataPath - ) - ).toBeNull() - expect( - resolveManagedCodexShellPreflightHome( - { CODEX_HOME: userHome, ORCA_CODEX_HOME: managedHome }, - userDataPath - ) - ).toBeNull() - expect( - resolveManagedCodexShellPreflightHome( - { CODEX_HOME: managedHome, ORCA_CODEX_HOME: undefined }, - userDataPath - ) - ).toBeNull() - }) - - it.skipIf(process.platform === 'win32')('rejects a shared-home symlink escape', () => { - const userDataPath = makeRoot() - const outside = makeRoot() - mkdirSync(join(userDataPath, 'codex-runtime-home')) - symlinkSync(outside, join(userDataPath, 'codex-runtime-home', 'home')) - const candidate = join(userDataPath, 'codex-runtime-home', 'home') - - expect( - resolveManagedCodexShellPreflightHome( - { CODEX_HOME: candidate, ORCA_CODEX_HOME: candidate }, - userDataPath - ) - ).toBeNull() - }) - - it.skipIf(process.platform === 'win32')('rejects an account-home symlink escape', () => { - const userDataPath = makeRoot() - const outside = makeRoot() - const accountDir = join(userDataPath, 'codex-accounts', 'account-1') - mkdirSync(accountDir, { recursive: true }) - writeFileSync(join(outside, '.orca-managed-home'), 'account-1\n') - symlinkSync(outside, join(accountDir, 'home')) - const candidate = join(accountDir, 'home') - - expect( - resolveManagedCodexShellPreflightHome( - { CODEX_HOME: candidate, ORCA_CODEX_HOME: candidate }, - userDataPath - ) - ).toBeNull() - }) - - it('rejects a parent traversal account marker', () => { - const userDataPath = makeRoot() - const candidate = join(userDataPath, 'home') - mkdirSync(join(userDataPath, 'codex-accounts')) - mkdirSync(candidate) - writeFileSync(join(candidate, '.orca-managed-home'), '..\n') - - expect( - resolveManagedCodexShellPreflightHome( - { CODEX_HOME: candidate, ORCA_CODEX_HOME: candidate }, - userDataPath - ) - ).toBeNull() - }) - - it.skipIf(process.platform === 'win32')('rejects a symlinked accounts root', () => { - const userDataPath = makeRoot() - const outside = makeRoot() - const candidate = join(userDataPath, 'codex-accounts', 'account-1', 'home') - mkdirSync(join(outside, 'account-1', 'home'), { recursive: true }) - writeFileSync(join(outside, 'account-1', 'home', '.orca-managed-home'), 'account-1\n') - symlinkSync(outside, join(userDataPath, 'codex-accounts')) - - expect( - resolveManagedCodexShellPreflightHome( - { CODEX_HOME: candidate, ORCA_CODEX_HOME: candidate }, - userDataPath - ) - ).toBeNull() - }) -}) - -describe('managed WSL Codex shell preflight', () => { - const home = '/home/jin/.local/share/orca/codex-runtime-home/home' - const runtimeHome = - '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home' - const env = { - CODEX_HOME: home, - ORCA_CODEX_HOME: home, - WSL_DISTRO_NAME: 'Ubuntu-24.04' - } - - it('targets the pane-selected managed home through its UNC twin', () => { - recordManagedWslCodexHome('Ubuntu-24.04', runtimeHome) - expect(resolveManagedWslCodexShellPreflightTarget(env)).toEqual({ - runtimeHomePath: runtimeHome, - wslDistro: 'Ubuntu-24.04' - }) - }) - - it('reconstructs a validated managed home when a restarted runtime has no record', () => { - expect(resolveManagedWslCodexShellPreflightTarget(env)).toEqual({ - runtimeHomePath: runtimeHome, - wslDistro: 'Ubuntu-24.04' - }) - }) - - it('installs once through the WSL runtime-home lane while hooks are enabled', async () => { - recordManagedWslCodexHome('Ubuntu-24.04', runtimeHome) - const status = { - agent: 'codex' as const, - state: 'installed' as const, - configPath: 'config.toml', - managedHooksPresent: true, - detail: null - } - const install = vi.fn(() => status) - - await expect( - prepareManagedWslCodexHomeBeforeShellLaunch({ env, hooksEnabled: true, install }) - ).resolves.toBe(status) - expect(install).toHaveBeenCalledExactlyOnceWith(runtimeHome, { - runtime: 'wsl', - wslDistro: 'Ubuntu-24.04' - }) - - await expect( - prepareManagedWslCodexHomeBeforeShellLaunch({ env, hooksEnabled: false, install }) - ).resolves.toBeNull() - expect(install).toHaveBeenCalledTimes(1) - }) - - it.each([ - [ - 'a user home', - { ...env, CODEX_HOME: '/home/jin/.codex', ORCA_CODEX_HOME: '/home/jin/.codex' } - ], - ['unequal routing markers', { ...env, ORCA_CODEX_HOME: `${home}-other` }], - [ - 'a parent traversal', - { - ...env, - CODEX_HOME: `/home/jin/../jin${home.slice('/home/jin'.length)}`, - ORCA_CODEX_HOME: `/home/jin/../jin${home.slice('/home/jin'.length)}` - } - ], - [ - 'a dot segment', - { - ...env, - CODEX_HOME: `/home/./jin${home.slice('/home/jin'.length)}`, - ORCA_CODEX_HOME: `/home/./jin${home.slice('/home/jin'.length)}` - } - ], - [ - 'a host path', - { ...env, CODEX_HOME: 'C:\\Users\\jin\\.codex', ORCA_CODEX_HOME: 'C:\\Users\\jin\\.codex' } - ], - ['a missing distro', { ...env, WSL_DISTRO_NAME: '' }], - ['a distro path escape', { ...env, WSL_DISTRO_NAME: 'Ubuntu\\..\\host' }], - [ - 'a repeated separator', - { - ...env, - CODEX_HOME: home.replace('/jin/', '/jin//'), - ORCA_CODEX_HOME: home.replace('/jin/', '/jin//') - } - ] - ])('rejects %s', (_label, candidate) => { - recordManagedWslCodexHome('Ubuntu-24.04', runtimeHome) - expect(resolveManagedWslCodexShellPreflightTarget(candidate)).toBeNull() - }) - - it('preserves a recorded runtime spelling for a managed account home', () => { - const directHome = '/home/jin/.local/share/orca/codex-accounts/account-1/home' - const directRuntimeHome = - '\\\\wsl$\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\codex-accounts\\account-1\\home' - recordManagedWslCodexHome('Ubuntu-24.04', directRuntimeHome) - - expect( - resolveManagedWslCodexShellPreflightTarget({ - CODEX_HOME: directHome, - ORCA_CODEX_HOME: directHome, - WSL_DISTRO_NAME: 'ubuntu-24.04' - }) - ).toEqual({ runtimeHomePath: directRuntimeHome, wslDistro: 'ubuntu-24.04' }) - }) - - it('never authorizes a WSL system home even when it was offered by a PTY lane', () => { - const systemHome = '/home/jin/.codex' - recordManagedWslCodexHome('Ubuntu-24.04', '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.codex') - - expect( - resolveManagedWslCodexShellPreflightTarget({ - CODEX_HOME: systemHome, - ORCA_CODEX_HOME: systemHome, - WSL_DISTRO_NAME: 'Ubuntu-24.04' - }) - ).toBeNull() - }) -}) diff --git a/src/main/codex/managed-home-shell-preflight.ts b/src/main/codex/managed-home-shell-preflight.ts deleted file mode 100644 index d94b31e23d71..000000000000 --- a/src/main/codex/managed-home-shell-preflight.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { existsSync, readFileSync, realpathSync } from 'node:fs' -import { join, relative, resolve, sep } from 'node:path' -import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' -import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' -import { codexHookService } from './hook-service' - -type ShellPreflightEnvironment = { - CODEX_HOME?: string - ORCA_CODEX_HOME?: string -} - -function pathsEqual(left: string, right: string): boolean { - return ( - normalizeRuntimePathForComparison(resolve(left)) === - normalizeRuntimePathForComparison(resolve(right)) - ) -} - -function realPathMatches(left: string, expectedRealPath: string): boolean { - try { - return pathsEqual(realpathSync.native(left), expectedRealPath) - } catch { - return false - } -} - -function resolveAccountManagedHome(candidate: string, userDataPath: string): string | null { - const accountsRoot = join(userDataPath, 'codex-accounts') - const pathParts = relative(accountsRoot, candidate).split(sep) - if (pathParts.length !== 2 || !pathParts[0] || pathParts[1] !== 'home') { - return null - } - const accountId = pathParts[0] - if (accountId === '.' || accountId === '..') { - return null - } - const expectedHome = join(accountsRoot, accountId, 'home') - let expectedRealHome: string - try { - expectedRealHome = join(realpathSync.native(userDataPath), 'codex-accounts', accountId, 'home') - } catch { - return null - } - if (!pathsEqual(candidate, expectedHome) || !realPathMatches(candidate, expectedRealHome)) { - return null - } - try { - return readFileSync(join(candidate, '.orca-managed-home'), 'utf-8').trim() === accountId - ? expectedHome - : null - } catch { - return null - } -} - -export function resolveManagedCodexShellPreflightHome( - env: ShellPreflightEnvironment, - userDataPath: string -): string | null { - const codexHome = env.CODEX_HOME?.trim() - const orcaCodexHome = env.ORCA_CODEX_HOME?.trim() - if (!codexHome || !orcaCodexHome || !pathsEqual(codexHome, orcaCodexHome)) { - return null - } - const sharedHome = join(userDataPath, 'codex-runtime-home', 'home') - if (pathsEqual(codexHome, sharedHome)) { - let expectedRealHome: string - try { - expectedRealHome = join(realpathSync.native(userDataPath), 'codex-runtime-home', 'home') - } catch { - return null - } - return existsSync(sharedHome) && realPathMatches(codexHome, expectedRealHome) - ? sharedHome - : null - } - return resolveAccountManagedHome(codexHome, userDataPath) -} - -/** - * Shell-startup preflight for a managed CODEX_HOME. - * - * Async because the Codex install awaits an app-server trust-grant session - * in-process. The old lane forked that session through spawnSync purely to - * borrow an event loop; the CLI already has one, so awaiting here removes a - * whole ELECTRON_RUN_AS_NODE process from every managed-home shell launch. - */ -export async function prepareManagedCodexHomeBeforeShellLaunch(args: { - env?: ShellPreflightEnvironment - userDataPath: string - hooksEnabled: boolean - install?: (runtimeHomePath: string) => AgentHookInstallStatus | Promise -}): Promise { - if (!args.hooksEnabled) { - return null - } - const env = args.env ?? { - CODEX_HOME: process.env.CODEX_HOME, - ORCA_CODEX_HOME: process.env.ORCA_CODEX_HOME - } - const runtimeHomePath = resolveManagedCodexShellPreflightHome(env, args.userDataPath) - if (!runtimeHomePath) { - return null - } - return (args.install ?? ((home) => codexHookService.install(home)))(runtimeHomePath) -} diff --git a/src/main/codex/windows-hook-command.test.ts b/src/main/codex/windows-hook-command.test.ts index d1fc2c8b098a..1b24dd12c129 100644 --- a/src/main/codex/windows-hook-command.test.ts +++ b/src/main/codex/windows-hook-command.test.ts @@ -13,30 +13,32 @@ import { } from '../agent-hooks/installer-utils' vi.mock('electron', () => ({ app: { getPath: () => process.cwd() } })) -afterEach(() => vi.restoreAllMocks()) +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() +}) describe('Codex Windows hook command', () => { it.each(['测试用户', '홍길동', '日本語', 'rené', '测试 用户', "测试 O'Brien"])( - 'uses the existing PowerShell host for %s without a second interpreter', + 'runs the script through cmd.exe for %s without an encoded launcher', (profile) => { vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + vi.stubEnv('SystemRoot', 'D:\\Windows') const path = `C:\\Users\\${profile}\\.orca\\agent-hooks\\codex-hook.cmd` const command = getManagedCommand(path) expect(command).not.toMatch(/powershell\.exe|EncodedCommand|Set-ExecutionPolicy/) - expect(command).toContain(`-LiteralPath '${path.replaceAll("'", "''")}' -PathType Leaf`) - expect(command).toContain(`[Console]::In.ReadToEnd()`) + expect(command).toBe( + `D:\\Windows\\System32\\cmd.exe --% /d /v:off /c @"${path.replaceAll('\\', '/')}"` + ) expect(createManagedCommandMatcher('codex-hook.cmd')(command)).toBe(true) expect(wrapWindowsCmdHookCommand(path)).toContain('-EncodedCommand') } ) - it('preserves the existing ASCII command and POSIX launcher', () => { + it('writes a bare forward-slash path when the profile is one token', () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') - const path = 'C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd' - expect(getManagedCommand(path)).toBe(path) - vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') - expect(getManagedCommand('/home/测试/.orca/agent-hooks/codex-hook.sh')).toContain( - "[ -x '/home/测试/.orca/agent-hooks/codex-hook.sh' ]" + expect(getManagedCommand('C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd')).toBe( + 'C:/Users/alice/.orca/agent-hooks/codex-hook.cmd' ) }) }) @@ -129,12 +131,10 @@ describe.skipIf(process.platform !== 'win32')('Codex hook delivery through Power timedOut: false }) rmSync(scriptPath) - expect(await invoke(getManagedCommand(scriptPath), payloads[0])).toMatchObject({ - code: 0, - stdout: '', - stderr: '', - timedOut: false - }) + // Why: like the bare path, the cmd spelling exits non-zero for a missing script. + const missing = await invoke(getManagedCommand(scriptPath), payloads[0]) + expect(missing.timedOut).toBe(false) + expect(missing.code).not.toBe(0) expect(posts).toHaveLength(CODEX_EVENTS.length) } finally { await new Promise((resolve) => server.close(() => resolve())) diff --git a/src/main/daemon/pty-subprocess-io-failure-native.test.ts b/src/main/daemon/pty-subprocess-io-failure-native.test.ts index b1835c068a79..415a47039588 100644 --- a/src/main/daemon/pty-subprocess-io-failure-native.test.ts +++ b/src/main/daemon/pty-subprocess-io-failure-native.test.ts @@ -1,12 +1,18 @@ import { fstatSync } from 'node:fs' -import * as pty from 'node-pty' -import { describe, expect, it, vi } from 'vitest' +import type * as pty from 'node-pty' +import { beforeAll, describe, expect, it, vi } from 'vitest' +import { loadNodePtyForTests } from '../orcad/orcad-node-slot-fixture' import { createDaemonPtySubprocessHandle } from './pty-subprocess/subprocess-handle' import { TerminalHost } from './terminal-host' const describePosix = process.platform === 'win32' ? describe.skip : describe describePosix('failed-I/O teardown with a real native PTY', () => { + let nodePty: typeof pty + beforeAll(async () => { + nodePty = await loadNodePtyForTests() + }) + it.each([ ['write', false], ['write', true], @@ -16,7 +22,7 @@ describePosix('failed-I/O teardown with a real native PTY', () => { 'reaps real shells and master fds after %s failure (immediate=%s)', async (operation, immediate) => { for (let cycle = 0; cycle < 4; cycle++) { - const native = pty.spawn( + const native = nodePty.spawn( '/bin/sh', [ '-c', diff --git a/src/main/daemon/pty-subprocess/bun-pty-job-control.integration.test.ts b/src/main/daemon/pty-subprocess/bun-pty-job-control.integration.test.ts deleted file mode 100644 index 2791f8f363b9..000000000000 --- a/src/main/daemon/pty-subprocess/bun-pty-job-control.integration.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { existsSync, mkdtempSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' -import { describe, expect, it } from 'vitest' -import { runProcess } from '../../../shared/child-process/run-process' -import { orcadBunRuntimeFilename } from '../../../shared/orcad-artifacts' -import { removeTreeSync } from '../../../shared/windows-transient-lock-removal' - -const runtimePath = - process.env.BUN_EXECUTABLE ?? - resolve(__dirname, '../../../../out/orcad', orcadBunRuntimeFilename(process.platform)) - -describe.skipIf( - process.platform === 'win32' || !existsSync(runtimePath) || !existsSync('/bin/bash') -)('Bun terminal user job control', () => { - // Bash re-raises SIGHUP; Zsh exits with the signal number. - for (const [shell, expectedExitCode] of [ - ['/bin/bash', 129], - ['/bin/zsh', 1] - ] as const) { - it.skipIf(!existsSync(shell))( - `gracefully closes an interactive ${shell} before the daemon force-kill deadline`, - async () => { - const directory = mkdtempSync(join(tmpdir(), 'orca-bun-shell-hangup-')) - try { - const entry = join(directory, 'shell-hangup.cjs') - writeFileSync( - entry, - ` -const {spawnBunPty} = require(${JSON.stringify(join(__dirname, 'bun-pty-process.ts'))}) -const {createDaemonPtySubprocessHandle} = require(${JSON.stringify(join(__dirname, 'subprocess-handle.ts'))}) -const {SessionTerminationController} = require(${JSON.stringify(join(__dirname, '../session-termination-controller.ts'))}) -const {existsSync} = require('node:fs') -const {join} = require('node:path') -const cwd = ${JSON.stringify(directory)} -const ready = join(cwd, 'ready'), cleanup = join(cwd, 'hangup-cleanup') -const shell = ${JSON.stringify(shell)} -const env = {...process.env,PS1:'',ORCA_TEST_READY:ready,ORCA_TEST_CLEANUP:cleanup} -const proc = spawnBunPty({file:shell,args:shell.endsWith('/bash')?['--noprofile','--norc','-i']:['-f','-i'],cwd,env,cols:80,rows:24}) -const subprocess = createDaemonPtySubprocessHandle({process:proc,shellPath:shell,spawnCwd:cwd,env,startupCommandDeliveredInShellArgs:false,reportsChildExitStatus:true,sessionId:'shell-hangup',startupAgentRecognition:null}) -let exited = false, forced = false, exitCode, elapsedMs, startedAt -const forceKill = subprocess.forceKill -subprocess.forceKill = () => {forced = true;forceKill()} -const controller = new SessionTerminationController({sessionId:'shell-hangup',subprocess,launchAgent:null,isExited:()=>exited,releaseProducerPause:()=>proc.resume()}) -subprocess.onExit(code => { - exited = true - exitCode = code - elapsedMs = Date.now() - startedAt - controller.markPhysicalExit() - controller.cancelForceKillFallback() -}) -const waitFor = async predicate => { - const deadline = Date.now() + 8000 - while (!predicate()) { - if (Date.now() >= deadline) throw new Error('Timed out waiting for shell hangup') - await Bun.sleep(10) - } -} -;(async()=>{ - try { - // Observe normal hangup cleanup without replacing the shell's SIGHUP handler. - proc.write(${JSON.stringify('trap \'printf cleaned > "$ORCA_TEST_CLEANUP"\' EXIT; printf ready > "$ORCA_TEST_READY"\r')}) - await waitFor(() => existsSync(ready)) - startedAt = Date.now() - controller.kill() - await waitFor(() => exited) - let reaped = false - try {process.kill(proc.pid, 0)} catch (error) {if(error.code==='ESRCH')reaped=true;else throw error} - console.log(JSON.stringify({cleaned:existsSync(cleanup),forced,exitCode,elapsedMs,reaped})) - } finally { - controller.cancelForceKillFallback() - if (!exited) { - subprocess.forceKill() - await waitFor(() => exited) - } - controller.disposeSubprocessHandle() - } -})().catch(error => {console.error(error);process.exitCode=1}) -` - ) - const result = await runProcess({ - program: runtimePath, - args: [entry], - timeoutMs: 25_000 - }) - expect(result.timedOut).toBe(false) - expect(result.code, result.stderr).toBe(0) - const evidence = JSON.parse(result.stdout) - expect(evidence).toEqual({ - cleaned: true, - forced: false, - exitCode: expectedExitCode, - elapsedMs: expect.any(Number), - reaped: true - }) - expect(evidence.elapsedMs).toBeLessThan(5_000) - } finally { - removeTreeSync(directory) - } - } - ) - } - - it('keeps a real Ctrl-Z job suspended while pausing and resuming a background producer', async () => { - const directory = mkdtempSync(join(tmpdir(), 'orca-bun-job-control-')) - try { - writeFileSync(join(directory, 'producer.cjs'), 'setInterval(()=>console.log("flow-tick"),10)') - writeFileSync( - join(directory, 'sleeper.sh'), - 'printf \'%s\' "$$" > sleeper-ready\nexec sleep 30\n' - ) - const entry = join(directory, 'job-control.cjs') - writeFileSync( - entry, - ` -const {spawnBunPty} = require(${JSON.stringify(join(__dirname, 'bun-pty-process.ts'))}) -const {readPosixPtyProcessTable,forceKillPosixPtyProcessGroups} = require(${JSON.stringify(join(__dirname, '../../pty/posix-pty-process-groups.ts'))}) -const {existsSync,readFileSync} = require('node:fs') -const ready = ${JSON.stringify(join(directory, 'sleeper-ready'))} -const signals = [] -const proc = spawnBunPty({ - file:'/bin/bash', args:['--noprofile','--norc','-i'], cwd:${JSON.stringify(directory)}, - env:{...process.env,PS1:'',ORCA_TEST_RUNTIME:process.execPath},cols:80,rows:24 -}, {signalProcessGroup:(pgid,signal)=>{process.kill(-pgid,signal);signals.push([pgid,signal])}}) -let output = '', exited = false -proc.onData(data => output += data) -proc.onExit(() => {exited = true}) -const isAlive = pid => { - try {process.kill(pid, 0);return true} - catch (error) {if(error.code==='ESRCH')return false;throw error} -} -const rows = async () => { - const table = (await readPosixPtyProcessTable(proc.pid)).trim().split(/\\r?\\n/).map(row => { - const [pid,pgid,tty,state] = row.trim().split(/\\s+/) - return {pid:Number(pid),pgid:Number(pgid),tty,state} - }).filter(row => row.pid > 0 && row.state) - const root = table.find(row => row.pid === proc.pid) - // BusyBox discovery returns all processes; this probe owns only its shell's terminal. - return root ? table.filter(row => row.tty === root.tty) : [] -} -const waitFor = async predicate => { - const deadline = Date.now() + 8000 - while (Date.now() < deadline) { - const value = await predicate() - if (value) return value - await Bun.sleep(20) - } - throw new Error('Timed out waiting for terminal process state') -} -;(async()=>{ - try { - proc.write('/bin/bash sleeper.sh\\r') - // A forked PID can appear before Bash gives its group the foreground terminal. - const sleeperPid = await waitFor(() => existsSync(ready) && Number(readFileSync(ready, 'utf8'))) - const sleeper = await waitFor(async () => (await rows()).find(row => row.pid === sleeperPid)) - proc.write('\\x1a') - await waitFor(async () => (await rows()).some(row => row.pid === sleeper.pid && row.state.startsWith('T'))) - proc.write(${JSON.stringify('"$ORCA_TEST_RUNTIME" producer.cjs &\r')}) - await waitFor(() => output.split('flow-tick').length > 5) - proc.pause() - await waitFor(() => signals.filter(([,signal]) => signal === 'SIGSTOP').length >= 2) - await Bun.sleep(100) - const pausedLength = output.length - await Bun.sleep(100) - const producerPaused = pausedLength === output.length - proc.resume() - await waitFor(() => signals.some(([,signal]) => signal === 'SIGCONT')) - await waitFor(() => output.length > pausedLength) - const sleeperAfter = (await rows()).find(row => row.pid === sleeper.pid) - console.log(JSON.stringify({producerPaused,producerResumed:true,userJobStopped:sleeperAfter?.state.startsWith('T')===true,userJobSignalled:signals.some(([pgid])=>pgid===sleeper.pgid)})) - } finally { - let ownedPids = [] - try { - proc.resume() - const ownedRows = await rows() - ownedPids = ownedRows.map(row => row.pid) - const root = ownedRows.find(row => row.pid === proc.pid) - if (!root) throw new Error('Cleanup could not find the owned shell') - // Keep Bash running until it reaps its jobs; container PID 1 may not reap orphans. - forceKillPosixPtyProcessGroups(proc.pid, () => {throw new Error('Cleanup lost terminal ownership')}, { - signalProcessGroup: pgid => {if (pgid !== root.pgid) process.kill(-pgid, 'SIGKILL')} - }) - process.kill(proc.pid, 'SIGCONT') - await waitFor(() => ownedPids.every(pid => pid === proc.pid || !isAlive(pid))) - } finally { - try { - forceKillPosixPtyProcessGroups(proc.pid, () => proc.kill('SIGKILL')) - await waitFor(() => exited && ownedPids.every(pid => !isAlive(pid))) - } finally { - proc.destroy() - } - } - } -})().catch(error => {console.error(error);process.exitCode=1}) -` - ) - const result = await runProcess({ program: runtimePath, args: [entry], timeoutMs: 25_000 }) - expect(result.timedOut).toBe(false) - expect(result.code, result.stderr).toBe(0) - expect(JSON.parse(result.stdout)).toEqual({ - producerPaused: true, - producerResumed: true, - userJobStopped: true, - userJobSignalled: false - }) - } finally { - removeTreeSync(directory) - } - }) -}) diff --git a/src/main/daemon/pty-subprocess/bun-pty-process-capabilities.ts b/src/main/daemon/pty-subprocess/bun-pty-process-capabilities.ts deleted file mode 100644 index 8d236ab5aa8b..000000000000 --- a/src/main/daemon/pty-subprocess/bun-pty-process-capabilities.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { BunRuntime } from './bun-pty-process-contract' - -function currentRuntime(): unknown { - return 'Bun' in globalThis ? globalThis.Bun : undefined -} - -function isBunRuntime(runtime: unknown): runtime is BunRuntime { - return ( - typeof runtime === 'object' && - runtime !== null && - 'spawn' in runtime && - typeof runtime.spawn === 'function' && - 'Terminal' in runtime && - typeof runtime.Terminal === 'function' - ) -} - -export function canUseBunPty(runtime: unknown = currentRuntime()): boolean { - return isBunRuntime(runtime) -} - -export function resolveBunRuntime(runtime: unknown = currentRuntime()): BunRuntime { - if (!isBunRuntime(runtime)) { - throw new Error('Bun terminal runtime is unavailable') - } - return runtime -} diff --git a/src/main/daemon/pty-subprocess/bun-pty-process-contract.ts b/src/main/daemon/pty-subprocess/bun-pty-process-contract.ts deleted file mode 100644 index b43dd57a659d..000000000000 --- a/src/main/daemon/pty-subprocess/bun-pty-process-contract.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type * as pty from 'node-pty' -import type { JobTerminationOutcome } from '../../windows/windows-pty-job' -import type { WindowsBunPtyJob } from './windows-bun-pty-job' -import type { createWindowsBunPtyLaunch } from './windows-bun-pty-launch' - -export type BunTerminal = { - closed: boolean - write(data: string | ArrayBufferView): number - resize(cols: number, rows: number): void - close(): void -} - -export type BunSubprocess = { - pid: number - terminal: BunTerminal - exited: Promise - signalCode?: string | null - kill(signal?: string | number): void -} - -export type BunTerminalOptions = { - cols: number - rows: number - name: string - data(terminal: BunTerminal, data: Uint8Array): void - exit?(terminal: BunTerminal, exitCode: number, signal: string | null): void - drain?(terminal: BunTerminal): void -} - -export type BunRuntime = { - Terminal: new (options: BunTerminalOptions) => BunTerminal - spawn( - command: string[], - options: { - cwd: string - env: Record - terminal: BunTerminal | BunTerminalOptions - windowsVerbatimArguments?: boolean - onExit?(process: BunSubprocess, exitCode: number, signalCode: string | null): void - } - ): BunSubprocess -} - -export type BunPtyProcess = pty.IPty & { - destroy(): void - processNameIsSpawnFile?: true - jobRootProcessIsWrapper?: true - shellProcessId?: number - waitForSpawn?(): Promise - terminateOwnedTree?(): JobTerminationOutcome - listOwnedProcessIds?(): readonly number[] | null - signalProcess?(signal: string): void -} - -export type BunPtySpawnArgs = { - file: string - args: string[] - cwd: string - env: Record - cols: number - rows: number -} - -export type SpawnBunPtyDeps = { - platform?: NodeJS.Platform - runtime?: BunRuntime - assignHostJob?: () => boolean - createJob?: (pid: number) => WindowsBunPtyJob | null - createWindowsLaunch?: typeof createWindowsBunPtyLaunch - readProcessTable?: () => string - signalProcessGroup?: (pgid: number, signal: NodeJS.Signals) => void -} diff --git a/src/main/daemon/pty-subprocess/bun-pty-process-flow-control.test.ts b/src/main/daemon/pty-subprocess/bun-pty-process-flow-control.test.ts deleted file mode 100644 index 5c4a438238b8..000000000000 --- a/src/main/daemon/pty-subprocess/bun-pty-process-flow-control.test.ts +++ /dev/null @@ -1,387 +0,0 @@ -import { constants } from 'node:os' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { createBunPtyProducerFlowControl } from './bun-pty-process-flow-control' - -const TABLE = '4321 4321 pts/test T\n4322 4322 pts/test' -const settled = (): Promise => new Promise((resolve) => setImmediate(resolve)) - -function createHarness() { - let exited = false - const reads: { resolve: (table: string) => void; signal: AbortSignal }[] = [] - const readProcessTableAsync = vi.fn( - (signal: AbortSignal) => - new Promise((resolve) => { - reads.push({ resolve, signal }) - }) - ) - const signalProcessGroup = vi.fn<(pgid: number, signal: NodeJS.Signals) => void>() - const kill = vi.fn() - const flow = createBunPtyProducerFlowControl({ - platform: 'linux', - processHandle: { pid: 4321, kill, terminal: { closed: false, close() {} } }, - windowsJob: null, - isExited: () => exited, - readProcessTable: () => TABLE, - readProcessTableAsync, - signalProcessGroup - }) - return { - flow, - reads, - kill, - readProcessTableAsync, - signalProcessGroup, - exit: () => { - exited = true - } - } -} - -afterEach(() => vi.useRealTimers()) - -describe('asynchronous POSIX producer flow control', () => { - it.each(['S', 'R', ''])( - 'leaves jobs running unless the shell is observed stopped (state %s)', - async (state) => { - const harness = createHarness() - harness.flow.pause() - expect(harness.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]]) - expect(harness.readProcessTableAsync).not.toHaveBeenCalled() - await settled() - harness.reads[0].resolve(TABLE.replace('pts/test T', `pts/test ${state}`)) - await settled() - expect(harness.signalProcessGroup).not.toHaveBeenCalled() - harness.flow.resume() - await settled() - harness.reads[1].resolve(TABLE) - await settled() - expect(harness.kill.mock.calls).toEqual([ - [constants.signals.SIGSTOP], - [constants.signals.SIGCONT] - ]) - expect(harness.signalProcessGroup).not.toHaveBeenCalled() - } - ) - - it('does not attempt a group pause after the owned shell cannot be stopped', async () => { - const harness = createHarness() - harness.kill.mockImplementationOnce(() => { - throw Object.assign(new Error('denied'), { code: 'EPERM' }) - }) - harness.flow.pause() - await settled() - harness.flow.resume() - await settled() - expect(harness.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]]) - expect(harness.readProcessTableAsync).not.toHaveBeenCalled() - expect(harness.signalProcessGroup).not.toHaveBeenCalled() - }) - - it('retries a failed resume lookup without another caller resume or stale group signals', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const harness = createHarness() - harness.flow.pause() - await settled() - harness.reads[0].resolve(TABLE) - await settled() - harness.readProcessTableAsync.mockRejectedValueOnce(new Error('temporary ps failure')) - harness.flow.resume() - await settled() - expect(harness.signalProcessGroup.mock.calls).toEqual([ - [4321, 'SIGSTOP'], - [4322, 'SIGSTOP'] - ]) - expect(harness.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]]) - - await vi.advanceTimersByTimeAsync(1_000) - expect(harness.readProcessTableAsync).toHaveBeenCalledTimes(3) - harness.reads[1].resolve('4321 4321 pts/test T\n4322 4322 pts/other\n4323 4323 pts/test') - await settled() - expect(harness.signalProcessGroup.mock.calls.slice(2)).toEqual([[4321, 'SIGCONT']]) - await vi.advanceTimersByTimeAsync(5_000) - expect(harness.readProcessTableAsync).toHaveBeenCalledTimes(3) - }) - - it.each(['pause', 'shutdown', 'exit'] as const)( - 'cancels a scheduled resume retry after %s', - async (action) => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const harness = createHarness() - harness.flow.pause() - await settled() - harness.reads[0].resolve(TABLE) - await settled() - harness.readProcessTableAsync.mockRejectedValueOnce(new Error('temporary ps failure')) - harness.flow.resume() - await settled() - expect(vi.getTimerCount()).toBe(1) - if (action === 'pause') { - harness.flow.pause() - await settled() - harness.reads[1].resolve(TABLE) - await settled() - } else { - if (action === 'exit') { - harness.exit() - } - harness.flow.resumeForShutdown() - } - expect(vi.getTimerCount()).toBe(0) - const signals = harness.signalProcessGroup.mock.calls.length - await vi.advanceTimersByTimeAsync(5_000) - expect(harness.signalProcessGroup).toHaveBeenCalledTimes(signals) - expect(harness.readProcessTableAsync).toHaveBeenCalledTimes(action === 'pause' ? 3 : 2) - } - ) - - it('bounds retries while discovery stays unavailable and resumes after it recovers', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const harness = createHarness() - harness.flow.pause() - await settled() - harness.reads[0].resolve(TABLE) - await settled() - harness.readProcessTableAsync.mockRejectedValue(new Error('ps unavailable')) - harness.flow.resume() - await settled() - await vi.advanceTimersByTimeAsync(2_000) - expect(harness.readProcessTableAsync).toHaveBeenCalledTimes(6) - expect(vi.getTimerCount()).toBe(1) - expect(harness.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]]) - expect(harness.signalProcessGroup).toHaveBeenCalledTimes(2) - harness.readProcessTableAsync.mockResolvedValue(TABLE) - await vi.advanceTimersByTimeAsync(500) - expect(harness.signalProcessGroup).toHaveBeenLastCalledWith(4321, 'SIGCONT') - expect(vi.getTimerCount()).toBe(0) - }) - - it('resumes a root-only suspension when process group discovery is unavailable throughout', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const harness = createHarness() - harness.readProcessTableAsync.mockRejectedValue(new Error('ps unavailable')) - harness.flow.pause() - await settled() - harness.flow.resume() - await settled() - expect(harness.kill.mock.calls).toEqual([ - [constants.signals.SIGSTOP], - [constants.signals.SIGCONT] - ]) - expect(harness.signalProcessGroup).not.toHaveBeenCalled() - expect(vi.getTimerCount()).toBe(0) - }) - - it('reapplies pause after a partial resume and keeps the shell stopped until all jobs resume', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const harness = createHarness() - const table = `${TABLE}\n4323 4323 pts/test` - let denyOnce = true - harness.signalProcessGroup.mockImplementation((pgid, signal) => { - if (pgid === 4323 && signal === 'SIGCONT' && denyOnce) { - denyOnce = false - throw Object.assign(new Error('denied'), { code: 'EPERM' }) - } - }) - harness.flow.pause() - await settled() - harness.reads[0].resolve(table) - await settled() - harness.flow.resume() - await settled() - harness.reads[1].resolve(table) - await settled() - expect(harness.signalProcessGroup.mock.calls.slice(3)).toEqual([ - [4322, 'SIGCONT'], - [4323, 'SIGCONT'] - ]) - expect(vi.getTimerCount()).toBe(1) - harness.flow.pause() - await settled() - harness.reads[2].resolve(table) - await settled() - expect(harness.signalProcessGroup.mock.calls.slice(5)).toEqual([ - [4321, 'SIGSTOP'], - [4322, 'SIGSTOP'], - [4323, 'SIGSTOP'] - ]) - expect(vi.getTimerCount()).toBe(0) - harness.flow.resume() - await settled() - harness.reads[3].resolve(table) - await settled() - expect(harness.signalProcessGroup.mock.calls.slice(8)).toEqual([ - [4322, 'SIGCONT'], - [4323, 'SIGCONT'], - [4321, 'SIGCONT'] - ]) - }) - - it('does not delay the shell resume for a job group that already exited', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const harness = createHarness() - harness.flow.pause() - await settled() - harness.reads[0].resolve(TABLE) - await settled() - harness.signalProcessGroup.mockImplementationOnce(() => { - throw Object.assign(new Error('gone'), { code: 'ESRCH' }) - }) - harness.flow.resume() - await settled() - harness.reads[1].resolve(TABLE) - await settled() - expect(harness.signalProcessGroup).toHaveBeenLastCalledWith(4321, 'SIGCONT') - expect(vi.getTimerCount()).toBe(0) - }) - - it('coalesces repeated pressure changes while discovery is pending', async () => { - const harness = createHarness() - harness.flow.pause() - await settled() - for (let i = 0; i < 1_000; i += 1) { - harness.flow.resume() - harness.flow.pause() - } - harness.flow.resume() - await settled() - expect(harness.readProcessTableAsync).toHaveBeenCalledOnce() - expect(harness.signalProcessGroup).not.toHaveBeenCalled() - - harness.reads[0].resolve(TABLE) - await settled() - expect(harness.signalProcessGroup).not.toHaveBeenCalled() - expect(harness.kill.mock.calls).toEqual([ - [constants.signals.SIGSTOP], - [constants.signals.SIGCONT] - ]) - - for (let i = 0; i < 20; i += 1) { - harness.flow.pause() - await settled() - harness.reads[2 * i + 1].resolve(TABLE) - await settled() - harness.flow.resume() - await settled() - harness.reads[2 * i + 2].resolve(TABLE) - await settled() - } - expect(harness.readProcessTableAsync).toHaveBeenCalledTimes(41) - expect(harness.signalProcessGroup).toHaveBeenCalledTimes(80) - expect(harness.signalProcessGroup).toHaveBeenLastCalledWith(4321, 'SIGCONT') - }) - - it('revalidates group ownership when resuming after a process id is reused', async () => { - const harness = createHarness() - harness.flow.pause() - await settled() - harness.reads[0].resolve(TABLE) - await settled() - harness.flow.resume() - await settled() - harness.reads[1].resolve('4321 4321 pts/test T\n4322 4322 pts/other\n4323 4323 pts/test') - await settled() - - expect(harness.signalProcessGroup.mock.calls).toEqual([ - [4321, 'SIGSTOP'], - [4322, 'SIGSTOP'], - [4321, 'SIGCONT'] - ]) - }) - - it('does not resume a still-paused session when pressure returns during a resume scan', async () => { - const harness = createHarness() - harness.flow.pause() - await settled() - harness.reads[0].resolve(TABLE) - await settled() - harness.flow.resume() - await settled() - harness.flow.pause() - harness.reads[1].resolve(TABLE) - await settled() - expect(harness.signalProcessGroup.mock.calls).toEqual([ - [4321, 'SIGSTOP'], - [4322, 'SIGSTOP'] - ]) - - harness.flow.resume() - await settled() - harness.reads[2].resolve(TABLE) - await settled() - expect(harness.signalProcessGroup).toHaveBeenLastCalledWith(4321, 'SIGCONT') - }) - - it.each(['shutdown', 'exit'] as const)('ignores a late scan after %s', async (action) => { - const harness = createHarness() - harness.flow.pause() - await settled() - if (action === 'shutdown') { - harness.flow.resumeForShutdown() - expect(harness.reads[0].signal.aborted).toBe(true) - } else { - harness.exit() - } - harness.reads[0].resolve(TABLE) - await settled() - expect(harness.signalProcessGroup).not.toHaveBeenCalled() - expect(harness.kill.mock.calls).toEqual( - action === 'shutdown' - ? [[constants.signals.SIGSTOP], [constants.signals.SIGCONT]] - : [[constants.signals.SIGSTOP]] - ) - }) - - it('releases stopped groups before shutdown while an asynchronous resume is pending', async () => { - const harness = createHarness() - harness.flow.pause() - await settled() - harness.reads[0].resolve(TABLE) - await settled() - harness.flow.resume() - await settled() - harness.flow.resumeForShutdown() - expect(harness.reads[1].signal.aborted).toBe(true) - expect(harness.signalProcessGroup.mock.calls).toEqual([ - [4321, 'SIGSTOP'], - [4322, 'SIGSTOP'], - [4322, 'SIGCONT'], - [4321, 'SIGCONT'] - ]) - harness.reads[1].resolve(TABLE) - await settled() - expect(harness.signalProcessGroup).toHaveBeenCalledTimes(4) - }) - - it('automatically retries a partially failed resume of a partially stopped tree', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const harness = createHarness() - const table = `${TABLE}\n4323 4323 pts/test S` - harness.signalProcessGroup.mockImplementation((pgid, signal) => { - if (pgid === 4323 && signal === 'SIGSTOP') { - throw new Error('temporary stop failure') - } - }) - harness.flow.pause() - await settled() - harness.reads[0].resolve(table) - await settled() - harness.signalProcessGroup.mockImplementationOnce(() => { - throw Object.assign(new Error('denied'), { code: 'EPERM' }) - }) - harness.flow.resume() - await settled() - harness.reads[1].resolve(table) - await settled() - await vi.advanceTimersByTimeAsync(500) - harness.reads[2].resolve(table) - await settled() - expect(harness.signalProcessGroup.mock.calls).toEqual([ - [4321, 'SIGSTOP'], - [4322, 'SIGSTOP'], - [4323, 'SIGSTOP'], - [4322, 'SIGCONT'], - [4322, 'SIGCONT'], - [4321, 'SIGCONT'] - ]) - }) -}) diff --git a/src/main/daemon/pty-subprocess/bun-pty-process-flow-control.ts b/src/main/daemon/pty-subprocess/bun-pty-process-flow-control.ts deleted file mode 100644 index 5b7dfd7a3133..000000000000 --- a/src/main/daemon/pty-subprocess/bun-pty-process-flow-control.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { constants } from 'node:os' -import type { WindowsBunPtyJob } from './windows-bun-pty-job' -import { isPosixPtyRootStopped, readPosixPtyProcessTable } from '../../pty/posix-pty-process-groups' - -import { createBunPtyProcessSuspension } from './bun-pty-process-suspension' - -const TRANSITION_RETRY_MS = 500 - -type BunPtyProcessHandle = Readonly<{ - pid: number - kill(signal?: string | number): void - terminal: Readonly<{ closed: boolean; close(): void }> -}> - -export type BunPtyProducerFlowControl = Readonly<{ - pause(): void - resume(): void - resumeForShutdown(): void -}> - -export function createBunPtyProducerFlowControl( - options: Readonly<{ - platform: NodeJS.Platform - processHandle: BunPtyProcessHandle - windowsJob: WindowsBunPtyJob | null - isExited: () => boolean - readProcessTable?: () => string - readProcessTableAsync?: (signal: AbortSignal) => Promise - signalProcessGroup?: (pgid: number, signal: NodeJS.Signals) => void - }> -): BunPtyProducerFlowControl { - let state: 'running' | 'paused' | 'uncertain' = 'running' - let pauseRequested = false - let shuttingDown = false - let pendingRead: AbortController | undefined - let transitionRetry: ReturnType | undefined - let pauseDenied = false - const signalRoot = (signal: 'SIGSTOP' | 'SIGCONT'): void => { - // The runtime's named STOP/CONT signals are not portable across POSIX platforms. - options.processHandle.kill(constants.signals[signal]) - } - - const suspension = createBunPtyProcessSuspension({ - pid: options.processHandle.pid, - platform: options.platform, - signalRoot, - readProcessTable: options.readProcessTable, - signalProcessGroup: options.signalProcessGroup - }) - const pausePermanentlyDenied = (error: unknown): boolean => - error instanceof Error && 'code' in error && (error.code === 'EPERM' || error.code === 'EACCES') - - const clearTransitionRetry = (): void => { - clearTimeout(transitionRetry) - transitionRetry = undefined - } - - const needsTransition = (): boolean => - !shuttingDown && !options.isExited() && state !== (pauseRequested ? 'paused' : 'running') - - const retryTransition = (): void => { - if (!needsTransition() || transitionRetry) { - return - } - // Callers send transitions once; retain the obligation until fresh ownership confirms every group. - transitionRetry = setTimeout(() => { - transitionRetry = undefined - reconcile() - }, TRANSITION_RETRY_MS) - transitionRetry.unref?.() - } - - const reconcile = (): void => { - if (!needsTransition() || pendingRead) { - return - } - if (options.platform === 'win32') { - const succeeded = pauseRequested ? options.windowsJob?.pause() : options.windowsJob?.resume() - state = succeeded ? (pauseRequested ? 'paused' : 'running') : 'uncertain' - if (pauseRequested && !succeeded) { - pauseRequested = false - } - retryTransition() - return - } - if (pauseRequested && state === 'running') { - try { - signalRoot('SIGSTOP') - state = 'uncertain' - } catch (error) { - if (pausePermanentlyDenied(error)) { - pauseDenied = true - pauseRequested = false - } - retryTransition() - return - } - } - const controller = new AbortController() - pendingRead = controller - // Process groups change as the shell runs jobs; revalidate them without blocking PTY output. - void Promise.resolve() - .then(() => - options.readProcessTableAsync - ? options.readProcessTableAsync(controller.signal) - : options.readProcessTable - ? options.readProcessTable() - : readPosixPtyProcessTable(options.processHandle.pid, controller.signal) - ) - .catch(() => '') - .then((table) => { - pendingRead = undefined - if (!needsTransition()) { - return - } - const nextPaused = pauseRequested - // Partial signals require a fresh transition even if the requested state changes again. - state = 'uncertain' - if (nextPaused) { - // Signal delivery is asynchronous; prove the shell stopped before suspending its jobs. - if (!isPosixPtyRootStopped(table, options.processHandle.pid)) { - retryTransition() - return - } - suspension.signal('SIGSTOP', table, true) - } else if (suspension.hasStoppedGroups()) { - suspension.signal('SIGCONT', table, true) - } else { - signalRoot('SIGCONT') - } - state = nextPaused ? 'paused' : 'running' - }) - .catch((error) => { - if (pauseRequested && pausePermanentlyDenied(error)) { - pauseDenied = true - pauseRequested = false - reconcile() - } else { - retryTransition() - } - }) - } - - return { - pause() { - if (shuttingDown || options.isExited() || pauseDenied) { - return - } - clearTransitionRetry() - pauseRequested = true - reconcile() - }, - resume() { - clearTransitionRetry() - pauseDenied = false - pauseRequested = false - reconcile() - }, - resumeForShutdown() { - clearTransitionRetry() - shuttingDown = true - if (options.platform === 'win32') { - if (!options.isExited()) { - options.windowsJob?.resume() - } - state = 'running' - return - } - pendingRead?.abort() - try { - if (!options.isExited() && state !== 'running') { - // Teardown must release stopped jobs before the root receives its exit signal. - if (suspension.hasStoppedGroups()) { - suspension.signal('SIGCONT') - } else { - signalRoot('SIGCONT') - } - } - } catch { - // A failed resume must not prevent the caller from terminating the PTY. - } - state = 'running' - } - } -} diff --git a/src/main/daemon/pty-subprocess/bun-pty-process-pause-retry.test.ts b/src/main/daemon/pty-subprocess/bun-pty-process-pause-retry.test.ts deleted file mode 100644 index cbda02305ea9..000000000000 --- a/src/main/daemon/pty-subprocess/bun-pty-process-pause-retry.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { constants } from 'node:os' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { createBunPtyProducerFlowControl } from './bun-pty-process-flow-control' - -const TABLE = '4321 4321 pts/test T\n4322 4322 pts/test S' -const settled = (): Promise => new Promise((resolve) => setImmediate(resolve)) - -function createHarness() { - let exited = false - const kill = vi.fn() - const signalProcessGroup = vi.fn() - const readProcessTableAsync = vi.fn<(signal: AbortSignal) => Promise>() - const flow = createBunPtyProducerFlowControl({ - platform: 'linux', - processHandle: { pid: 4321, kill, terminal: { closed: false, close() {} } }, - windowsJob: null, - isExited: () => exited, - readProcessTableAsync, - signalProcessGroup - }) - return { flow, kill, readProcessTableAsync, signalProcessGroup, exit: () => (exited = true) } -} - -afterEach(() => vi.useRealTimers()) - -describe('Bun producer pause retry', () => { - it('retries a failed root suspension before attempting any group signals', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const harness = createHarness() - harness.kill.mockImplementationOnce(() => { - throw new Error('temporary signal rejection') - }) - harness.readProcessTableAsync.mockResolvedValue(TABLE) - harness.flow.pause() - await settled() - expect(harness.readProcessTableAsync).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(500) - expect(harness.kill.mock.calls).toEqual([ - [constants.signals.SIGSTOP], - [constants.signals.SIGSTOP] - ]) - expect(harness.signalProcessGroup.mock.calls).toEqual([ - [4321, 'SIGSTOP'], - [4322, 'SIGSTOP'] - ]) - expect(vi.getTimerCount()).toBe(0) - }) - - it('keeps ownership discovery pending when the stopped root has no controlling tty', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const harness = createHarness() - harness.readProcessTableAsync.mockResolvedValueOnce('4321 4321 ? T').mockResolvedValue(TABLE) - harness.flow.pause() - await settled() - expect(harness.signalProcessGroup).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(500) - expect(harness.signalProcessGroup.mock.calls).toEqual([ - [4321, 'SIGSTOP'], - [4322, 'SIGSTOP'] - ]) - expect(vi.getTimerCount()).toBe(0) - }) - - it.each(['lookup failure', 'shell still running'])( - 'eventually stops jobs after a transient %s without another pause request', - async (failure) => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const harness = createHarness() - if (failure === 'lookup failure') { - harness.readProcessTableAsync.mockRejectedValueOnce(new Error('ps timed out')) - } else { - harness.readProcessTableAsync.mockResolvedValueOnce(TABLE.replace('test T', 'test S')) - } - harness.readProcessTableAsync.mockResolvedValue(TABLE) - harness.flow.pause() - await settled() - expect(harness.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]]) - expect(harness.signalProcessGroup).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(499) - expect(harness.readProcessTableAsync).toHaveBeenCalledOnce() - await vi.advanceTimersByTimeAsync(1) - expect(harness.signalProcessGroup.mock.calls).toEqual([ - [4321, 'SIGSTOP'], - [4322, 'SIGSTOP'] - ]) - expect(vi.getTimerCount()).toBe(0) - harness.flow.resume() - await settled() - expect(harness.signalProcessGroup.mock.calls.slice(2)).toEqual([ - [4322, 'SIGCONT'], - [4321, 'SIGCONT'] - ]) - } - ) - - it.each(['resume', 'shutdown', 'exit'] as const)( - 'bounds failed pause probes and cancels them after %s', - async (action) => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const harness = createHarness() - harness.readProcessTableAsync.mockRejectedValue(new Error('ps unavailable')) - harness.flow.pause() - await settled() - await vi.advanceTimersByTimeAsync(2_000) - expect(harness.readProcessTableAsync).toHaveBeenCalledTimes(5) - expect(vi.getTimerCount()).toBe(1) - expect(harness.signalProcessGroup).not.toHaveBeenCalled() - if (action === 'resume') { - harness.flow.resume() - } else { - if (action === 'exit') { - harness.exit() - } - harness.flow.resumeForShutdown() - } - await settled() - const reads = harness.readProcessTableAsync.mock.calls.length - await vi.advanceTimersByTimeAsync(5_000) - expect(harness.readProcessTableAsync).toHaveBeenCalledTimes(reads) - expect(vi.getTimerCount()).toBe(0) - expect(harness.kill.mock.calls).toEqual( - action === 'exit' - ? [[constants.signals.SIGSTOP]] - : [[constants.signals.SIGSTOP], [constants.signals.SIGCONT]] - ) - } - ) -}) diff --git a/src/main/daemon/pty-subprocess/bun-pty-process-runtime.ts b/src/main/daemon/pty-subprocess/bun-pty-process-runtime.ts deleted file mode 100644 index b64097950433..000000000000 --- a/src/main/daemon/pty-subprocess/bun-pty-process-runtime.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { constants } from 'node:os' -import { - assignCurrentProcessToBunPtyHostJob, - createWindowsBunPtyJob, - type WindowsBunPtyJob -} from './windows-bun-pty-job' -import { createWindowsBunPtyLaunch, type WindowsBunPtyLaunch } from './windows-bun-pty-launch' -import type { - BunPtyProcess, - BunPtySpawnArgs, - BunSubprocess, - BunTerminal, - BunTerminalOptions, - SpawnBunPtyDeps -} from './bun-pty-process-contract' -import { resolveBunRuntime } from './bun-pty-process-capabilities' -import { createBunPtyProducerFlowControl } from './bun-pty-process-flow-control' - -export function spawnBunPty(args: BunPtySpawnArgs, deps: SpawnBunPtyDeps = {}): BunPtyProcess { - const runtime = resolveBunRuntime(deps.runtime) - - const platform = deps.platform ?? process.platform - let processHandle: BunSubprocess - let windowsLaunch: WindowsBunPtyLaunch | null = null - let windowsJob: WindowsBunPtyJob | null = null - let windowsTerminal: BunTerminal | null = null - let processExitCode: number | undefined - let terminalFinished = false - let clearInFlight: Promise | null = null - const dataListeners = new Set<(data: string) => void>() - const exitListeners = new Set<(event: { exitCode: number; signal?: number }) => void>() - const decoder = new TextDecoder() - let pendingData = '' - let exited = false - let exitCode = 0 - let exitSignal: number | undefined - // Keep a closed Bun native handle from escaping as a daemon RPC failure. - let terminalUnavailable = false - let appliedCols = args.cols - let appliedRows = args.rows - - const emitData = (data: string): void => { - if (dataListeners.size === 0) { - pendingData = (pendingData + data).slice(-512 * 1024) - return - } - for (const listener of dataListeners) { - listener(data) - } - } - const onProcessExit = (code: number): void => { - processExitCode = code - windowsLaunch?.dispose() - if (!windowsTerminal || terminalFinished) { - emitExit(code) - return - } - // ConPTY closes off-thread; retain listeners until its final frame reaches EOF. - if (!windowsTerminal.closed) { - windowsTerminal.close() - } - } - - const emitExit = (code: number): void => { - if (exited) { - return - } - exited = true - producerFlowControl.resumeForShutdown() - windowsLaunch?.readShellProcessId() - exitCode = code - exitSignal = Object.entries(constants.signals).find( - ([name]) => name === processHandle.signalCode - )?.[1] - const pending = decoder.decode() - if (pending) { - emitData(pending) - } - for (const dispose of [ - () => (processHandle.terminal.closed ? undefined : processHandle.terminal.close()), - () => windowsJob?.close() - ]) { - try { - dispose() - } catch (error) { - console.warn('[daemon/pty] PTY cleanup failed:', error) - } - } - for (const listener of exitListeners) { - listener({ exitCode: code, ...(exitSignal === undefined ? {} : { signal: exitSignal }) }) - } - dataListeners.clear() - exitListeners.clear() - } - - if (platform === 'win32') { - if (!(deps.assignHostJob ?? assignCurrentProcessToBunPtyHostJob)()) { - throw new Error('Windows Bun PTY host crash ownership is unavailable') - } - windowsLaunch = (deps.createWindowsLaunch ?? createWindowsBunPtyLaunch)(args) - } - try { - const terminalOptions: BunTerminalOptions = { - cols: args.cols, - rows: args.rows, - name: args.env.TERM ?? 'xterm-256color', - data: (_terminal, data) => { - const decoded = decoder.decode(data, { stream: true }) - if (decoded) { - emitData(decoded) - } - }, - exit() { - terminalFinished = true - if (processExitCode !== undefined) { - emitExit(processExitCode) - } - } - } - // Inline Bun terminals cannot be reused by the Windows clear command. - if (windowsLaunch) { - windowsTerminal = new runtime.Terminal(terminalOptions) - } - processHandle = runtime.spawn(windowsLaunch?.command ?? [args.file, ...args.args], { - cwd: args.cwd, - env: windowsLaunch?.env ?? args.env, - ...(windowsLaunch - ? { - windowsVerbatimArguments: windowsLaunch.windowsVerbatimArguments - } - : {}), - terminal: windowsTerminal ?? terminalOptions - }) - } catch (error) { - windowsTerminal?.close() - windowsLaunch?.dispose() - throw error - } - if (windowsLaunch) { - try { - windowsJob = (deps.createJob ?? createWindowsBunPtyJob)(processHandle.pid) - if (!windowsJob) { - throw new Error('Windows Bun PTY job ownership is unavailable') - } - windowsLaunch.release() - } catch (error) { - windowsJob?.terminate() - try { - processHandle.kill('SIGTERM') - } catch { - // The failed gate release still owns cleanup through the job when available. - } - if (!processHandle.terminal.closed) { - processHandle.terminal.close() - } - windowsJob?.close() - windowsLaunch.dispose() - // A running gate can temporarily lock its private working directory on Windows. - const disposeLaunch = (): void => windowsLaunch?.dispose() - void processHandle.exited.then(disposeLaunch, disposeLaunch) - throw error - } - } - void processHandle.exited.then(onProcessExit, () => onProcessExit(1)) - - const producerFlowControl = createBunPtyProducerFlowControl({ - platform, - processHandle, - windowsJob, - isExited: () => exited, - ...(deps.readProcessTable ? { readProcessTable: deps.readProcessTable } : {}), - ...(deps.signalProcessGroup ? { signalProcessGroup: deps.signalProcessGroup } : {}) - }) - - const windowsCapabilities = windowsJob - ? { - waitForSpawn: () => windowsLaunch?.waitForSpawn(processHandle.exited) ?? Promise.resolve(), - terminateOwnedTree: () => windowsJob?.terminate() ?? 'unavailable', - listOwnedProcessIds: () => windowsJob?.listProcessIds() ?? null, - jobRootProcessIsWrapper: true as const, - signalProcess(signal: string) { - if (signal === 'SIGWINCH') { - return - } - if (windowsJob?.terminate() === 'terminated') { - return - } - try { - processHandle.kill(signal) - } finally { - if (!processHandle.terminal.closed) { - processHandle.terminal.close() - } - } - } - } - : {} - - const clearCapability = windowsLaunch - ? { - clear() { - if (exited || clearInFlight) { - return - } - try { - const clearProcess = runtime.spawn(windowsLaunch.clearCommand, { - cwd: args.cwd, - env: args.env, - terminal: processHandle.terminal, - windowsVerbatimArguments: true - }) - clearInFlight = clearProcess.exited - const settled = (): void => { - clearInFlight = null - } - void clearInFlight.then(settled, settled) - } catch { - clearInFlight = null - } - } - } - : {} - - const terminate = (signal: string): void => { - producerFlowControl.resumeForShutdown() - const treeTerminated = windowsJob?.terminate() === 'terminated' - try { - processHandle.kill(signal) - } catch (error) { - if (!treeTerminated) { - throw error - } - } - } - - return { - pid: processHandle.pid, - get shellProcessId() { - return windowsLaunch?.readShellProcessId() - }, - handleFlowControl: false, - processNameIsSpawnFile: true, - clear() {}, - process: args.file, - get cols() { - return appliedCols - }, - get rows() { - return appliedRows - }, - onData(listener) { - if (pendingData) { - const data = pendingData - pendingData = '' - listener(data) - } - if (exited) { - return { dispose() {} } - } - dataListeners.add(listener) - return { dispose: () => dataListeners.delete(listener) } - }, - onExit(listener) { - if (exited) { - listener({ exitCode, ...(exitSignal === undefined ? {} : { signal: exitSignal }) }) - return { dispose() {} } - } - exitListeners.add(listener) - return { dispose: () => exitListeners.delete(listener) } - }, - write(data) { - if (exited || terminalUnavailable || processHandle.terminal.closed) { - return - } - try { - processHandle.terminal.write(data) - } catch { - terminalUnavailable = true - } - }, - resize(cols, rows) { - if (exited || terminalUnavailable || processHandle.terminal.closed) { - return - } - try { - processHandle.terminal.resize(cols, rows) - appliedCols = cols - appliedRows = rows - } catch { - terminalUnavailable = true - } - }, - ...clearCapability, - ...producerFlowControl, - ...windowsCapabilities, - // Interactive POSIX shells ignore SIGTERM. - kill(signal = platform === 'win32' ? 'SIGTERM' : 'SIGHUP') { - if (!exited) { - terminate(signal) - } - }, - destroy() { - if (!exited) { - terminate(platform === 'win32' ? 'SIGTERM' : 'SIGHUP') - } - if (!processHandle.terminal.closed) { - processHandle.terminal.close() - } - } - } -} diff --git a/src/main/daemon/pty-subprocess/bun-pty-process-suspension.test.ts b/src/main/daemon/pty-subprocess/bun-pty-process-suspension.test.ts deleted file mode 100644 index da7bf0c09e3e..000000000000 --- a/src/main/daemon/pty-subprocess/bun-pty-process-suspension.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { constants } from 'node:os' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { createBunPtyProducerFlowControl } from './bun-pty-process-flow-control' - -const TABLE = '4321 4321 pts/test T\n4322 4322 pts/test S\n4323 4323 pts/test T' -const settled = (): Promise => new Promise((resolve) => setImmediate(resolve)) - -function harness(platform: NodeJS.Platform = 'linux') { - let exited = false - const kill = vi.fn() - const signalProcessGroup = vi.fn() - const readProcessTableAsync = vi.fn(async () => TABLE) - const windowsJob = { - listProcessIds: () => [], - pause: vi.fn(() => true), - resume: vi.fn(() => true), - terminate: () => 'terminated' as const, - close() {} - } - const flow = createBunPtyProducerFlowControl({ - platform, - processHandle: { pid: 4321, kill, terminal: { closed: false, close() {} } }, - windowsJob, - isExited: () => exited, - readProcessTable: () => TABLE, - readProcessTableAsync, - signalProcessGroup - }) - return { - flow, - kill, - signalProcessGroup, - readProcessTableAsync, - windowsJob, - exit: () => (exited = true) - } -} - -afterEach(() => vi.useRealTimers()) - -describe('flow-control suspension ownership', () => { - it.each(['resume', 'shutdown'] as const)( - 'preserves a Ctrl-Z stopped job during %s', - async (action) => { - const h = harness() - h.flow.pause() - await settled() - h.readProcessTableAsync.mockResolvedValue(TABLE.replace('4322 pts/test S', '4322 pts/test T')) - if (action === 'resume') { - h.flow.resume() - } else { - h.flow.resumeForShutdown() - } - await settled() - expect(h.signalProcessGroup.mock.calls).toEqual([ - [4321, 'SIGSTOP'], - [4322, 'SIGSTOP'], - [4322, 'SIGCONT'], - [4321, 'SIGCONT'] - ]) - } - ) - - it('does not resume a group it already released when another group needs a retry', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const h = harness() - h.readProcessTableAsync.mockResolvedValue(TABLE.replace('4323 pts/test T', '4323 pts/test S')) - let failed = false - h.signalProcessGroup.mockImplementation((pgid, signal) => { - if (pgid === 4323 && signal === 'SIGCONT' && !failed) { - failed = true - throw new Error('temporary resume failure') - } - }) - h.flow.pause() - await settled() - h.flow.resume() - await settled() - // The user can suspend a job again after its first successful resume. - h.readProcessTableAsync.mockResolvedValue(TABLE.replace('4322 pts/test S', '4322 pts/test T')) - await vi.advanceTimersByTimeAsync(500) - expect( - h.signalProcessGroup.mock.calls.filter( - ([pid, signal]) => pid === 4322 && signal === 'SIGCONT' - ) - ).toHaveLength(1) - expect(h.signalProcessGroup).toHaveBeenLastCalledWith(4321, 'SIGCONT') - expect(vi.getTimerCount()).toBe(0) - }) - - it.each(['EPERM', 'EACCES'])('does not retry a root pause denied with %s', async (code) => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const h = harness() - h.kill.mockImplementationOnce(() => { - throw Object.assign(new Error('denied'), { code }) - }) - h.flow.pause() - await settled() - h.flow.pause() - await vi.advanceTimersByTimeAsync(30_000) - expect(h.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]]) - expect(h.readProcessTableAsync).not.toHaveBeenCalled() - expect(vi.getTimerCount()).toBe(0) - h.flow.resume() - h.flow.pause() - await settled() - expect(h.kill).toHaveBeenCalledTimes(2) - h.flow.resumeForShutdown() - }) - - it('rolls back acquired stops after a denied job pause without repeatedly scanning or resuming the denied job', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const h = harness() - h.readProcessTableAsync.mockResolvedValue(`${TABLE}\n4324 4324 pts/test S`) - h.signalProcessGroup.mockImplementation((pgid, signal) => { - if (pgid === 4324 && signal === 'SIGSTOP') { - throw Object.assign(new Error('denied'), { code: 'EPERM' }) - } - }) - h.flow.pause() - await settled() - await vi.advanceTimersByTimeAsync(30_000) - expect(h.signalProcessGroup.mock.calls).toEqual([ - [4321, 'SIGSTOP'], - [4322, 'SIGSTOP'], - [4324, 'SIGSTOP'], - [4322, 'SIGCONT'], - [4321, 'SIGCONT'] - ]) - expect(h.readProcessTableAsync).toHaveBeenCalledTimes(2) - expect(vi.getTimerCount()).toBe(0) - }) - - it('retains the resume obligation when rollback after a denied pause also fails', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const h = harness() - let failed = false - h.signalProcessGroup.mockImplementation((pgid, signal) => { - if (pgid === 4322 && signal === 'SIGSTOP') { - throw Object.assign(new Error('denied'), { code: 'EPERM' }) - } - if (pgid === 4321 && signal === 'SIGCONT' && !failed) { - failed = true - throw Object.assign(new Error('resume denied'), { code: 'EPERM' }) - } - }) - h.flow.pause() - await settled() - expect(vi.getTimerCount()).toBe(1) - await vi.advanceTimersByTimeAsync(500) - expect(h.signalProcessGroup.mock.calls).toEqual([ - [4321, 'SIGSTOP'], - [4322, 'SIGSTOP'], - [4321, 'SIGCONT'], - [4321, 'SIGCONT'] - ]) - expect(vi.getTimerCount()).toBe(0) - }) -}) - -describe('Windows resume retries', () => { - it('releases a failed partial pause without repeatedly attempting the denied pause', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const h = harness('win32') - h.windowsJob.pause.mockReturnValueOnce(false) - h.windowsJob.resume.mockReturnValueOnce(false) - h.flow.pause() - await vi.advanceTimersByTimeAsync(500) - expect(h.windowsJob.resume).toHaveBeenCalledOnce() - await vi.advanceTimersByTimeAsync(500) - expect(h.windowsJob.resume).toHaveBeenCalledTimes(2) - expect(h.windowsJob.pause).toHaveBeenCalledOnce() - expect(vi.getTimerCount()).toBe(0) - h.flow.pause() - expect(h.windowsJob.pause).toHaveBeenCalledTimes(2) - h.flow.resumeForShutdown() - }) - - it('retries a failed resume without another caller transition', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const h = harness('win32') - h.windowsJob.resume.mockReturnValueOnce(false) - h.flow.pause() - h.flow.resume() - expect(h.windowsJob.resume).toHaveBeenCalledOnce() - await vi.advanceTimersByTimeAsync(500) - expect(h.windowsJob.resume).toHaveBeenCalledTimes(2) - expect(vi.getTimerCount()).toBe(0) - expect(h.readProcessTableAsync).not.toHaveBeenCalled() - }) - - it.each(['pause', 'shutdown', 'exit'] as const)( - 'cancels stale resume retries after %s', - async (action) => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const h = harness('win32') - h.windowsJob.resume.mockReturnValue(false) - h.flow.pause() - h.flow.resume() - if (action === 'pause') { - h.flow.pause() - } else { - if (action === 'exit') { - h.exit() - } - h.flow.resumeForShutdown() - } - const resumes = h.windowsJob.resume.mock.calls.length - await vi.advanceTimersByTimeAsync(5_000) - expect(h.windowsJob.resume).toHaveBeenCalledTimes(resumes) - expect(vi.getTimerCount()).toBe(0) - } - ) -}) diff --git a/src/main/daemon/pty-subprocess/bun-pty-process-suspension.ts b/src/main/daemon/pty-subprocess/bun-pty-process-suspension.ts deleted file mode 100644 index 3fc20931d5cf..000000000000 --- a/src/main/daemon/pty-subprocess/bun-pty-process-suspension.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { - getPosixPtyStoppedJobGroups, - signalPosixPtyProcessGroups -} from '../../pty/posix-pty-process-groups' - -export function createBunPtyProcessSuspension(options: { - pid: number - platform: NodeJS.Platform - signalRoot: (signal: 'SIGSTOP' | 'SIGCONT') => void - readProcessTable?: () => string - signalProcessGroup?: (pgid: number, signal: NodeJS.Signals) => void -}) { - const stoppedGroups = new Set() - return { - hasStoppedGroups: () => stoppedGroups.size > 0, - signal(signal: 'SIGSTOP' | 'SIGCONT', table?: string, requireGroups = false): void { - const alreadyStopped = - signal === 'SIGSTOP' && table !== undefined - ? getPosixPtyStoppedJobGroups(table, options.pid) - : new Set() - let resumeFailed = false - signalPosixPtyProcessGroups( - options.pid, - signal, - () => { - if (requireGroups) { - throw new Error('Paused PTY group ownership is unavailable') - } - options.signalRoot(signal) - }, - { - platform: options.platform, - ...(table !== undefined - ? { readProcessTable: () => table } - : options.readProcessTable - ? { readProcessTable: options.readProcessTable } - : {}), - signalProcessGroup(pgid) { - if ( - signal === 'SIGSTOP' - ? alreadyStopped.has(pgid) && !stoppedGroups.has(pgid) - : !stoppedGroups.has(pgid) - ) { - return - } - // Keep the shell stopped until every preceding job group has resumed. - if (signal === 'SIGCONT' && requireGroups && resumeFailed) { - throw new Error('An earlier PTY group could not be resumed') - } - try { - if (options.signalProcessGroup) { - options.signalProcessGroup(pgid, signal) - } else { - process.kill(-pgid, signal) - } - } catch (error) { - const gone = error instanceof Error && 'code' in error && error.code === 'ESRCH' - if (gone) { - stoppedGroups.delete(pgid) - } - resumeFailed = !gone - throw error - } - if (signal === 'SIGSTOP') { - stoppedGroups.add(pgid) - } else { - stoppedGroups.delete(pgid) - } - } - } - ) - if (signal === 'SIGCONT') { - // A fresh successful scan also retires groups that no longer belong to this terminal. - stoppedGroups.clear() - } - } - } -} diff --git a/src/main/daemon/pty-subprocess/bun-pty-process.integration.test.ts b/src/main/daemon/pty-subprocess/bun-pty-process.integration.test.ts deleted file mode 100644 index 1c4f0436d6f6..000000000000 --- a/src/main/daemon/pty-subprocess/bun-pty-process.integration.test.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { existsSync, mkdtempSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' -import { describe, expect, it } from 'vitest' -import { runProcess, runProcessSync } from '../../../shared/child-process/run-process' -import { orcadBunRuntimeFilename } from '../../../shared/orcad-artifacts' -import { ORCAD_BUN_VERSION } from '../../../shared/orcad-bun-runtime' -import { removeTreeSync } from '../../../shared/windows-transient-lock-removal' - -const runtimePath = - process.env.BUN_EXECUTABLE ?? - resolve(__dirname, '../../../../out/orcad', orcadBunRuntimeFilename(process.platform)) - -async function runTerminalScript(script: string): Promise { - expect(runProcessSync({ program: runtimePath, args: ['--version'] }).stdout.trim()).toBe( - ORCAD_BUN_VERSION - ) - const directory = mkdtempSync(join(tmpdir(), 'orca-bun-terminal-')) - try { - const entry = join(directory, 'terminal.cjs') - writeFileSync( - entry, - [ - `const {spawnBunPty} = require(${JSON.stringify(join(__dirname, 'bun-pty-process.ts'))})`, - `const args = {file: process.execPath, cwd: ${JSON.stringify(directory)}, env: process.env, cols: 80, rows: 24}`, - script - ].join('\n') - ) - const result = await runProcess({ program: runtimePath, args: [entry], timeoutMs: 30_000 }) - expect(result.timedOut).toBe(false) - expect(result.code, result.stderr).toBe(0) - return JSON.parse(result.stdout) - } finally { - removeTreeSync(directory) - } -} - -describe.skipIf(!existsSync(runtimePath) || process.platform === 'win32')( - 'real Bun terminal', - () => { - it('drains multi-byte output before publishing process exit and applies resize', async () => { - const result = await runTerminalScript(` - const expected = '⌘状態'.repeat(200_000) - const proc = spawnBunPty({...args, args: ['-e', 'process.stdout.write("⌘状態".repeat(200000));process.exitCode=17']}) - let output = '' - proc.resize(103, 37) - proc.onData(data => output += data) - proc.onExit(event => { - console.log(JSON.stringify({event, exact: output === expected, cols:proc.cols, rows:proc.rows})) - proc.destroy() - }) - `) - expect(result).toEqual({ event: { exitCode: 17 }, exact: true, cols: 103, rows: 37 }) - }) - - it('receives the real shell identity from a gated Bun subprocess', async () => { - const result = await runTerminalScript(` - const {createWindowsBunPtyLaunch} = require(${JSON.stringify(join(__dirname, 'windows-bun-pty-launch.ts'))}) - const proc = spawnBunPty({...args,args:['-e','setTimeout(()=>{process.exitCode=17},100)']}, { - platform:'win32', assignHostJob:()=>true, - createJob:()=>({listProcessIds:()=>[], pause:()=>true,resume:()=>true,terminate:()=> 'terminated',close(){}}), - createWindowsLaunch:launch => createWindowsBunPtyLaunch(launch, { - runtimePath:process.execPath,workerPath:${JSON.stringify(join(__dirname, 'windows-bun-pty-gate-entry.ts'))} - }) - }) - proc.onExit(event => { - console.log(JSON.stringify({event, shellIdentified:proc.shellProcessId>0 && proc.shellProcessId!==proc.pid})) - proc.destroy() - }) - `) - expect(result).toEqual({ event: { exitCode: 17 }, shellIdentified: true }) - }) - - it('reports signal termination distinctly from an ordinary exit', async () => { - const result = await runTerminalScript(` - const proc = spawnBunPty({...args,args:['-e', 'console.log("ready");setInterval(()=>{},1000)']}) - proc.onData(() => proc.kill('SIGTERM')) - proc.onExit(event => { console.log(JSON.stringify(event));proc.destroy() }) - `) - expect(result).toEqual({ exitCode: 143, signal: 15 }) - }) - - it('pauses and resumes the owned process when process discovery is unavailable', async () => { - const result = await runTerminalScript(` - const expected = 'ready' + 'x'.repeat(1024 * 1024) - const continueOutput = require('node:path').join(args.cwd, 'continue-output') - const proc = spawnBunPty({...args,env:{...args.env,ORCA_TEST_CONTINUE:continueOutput},args:['-e','process.stdout.write("ready");const timer=setInterval(()=>{if(!require("node:fs").existsSync(process.env.ORCA_TEST_CONTINUE))return;clearInterval(timer);process.stdout.write("x".repeat(1024*1024))},1)']},{readProcessTable:()=>''}) - let output = '', paused = false, stable = false - proc.onData(data => { - output += data - if (paused) return - paused = true - proc.pause() - setTimeout(() => { - const settled = output.length - require('node:fs').writeFileSync(continueOutput, 'continue') - setTimeout(() => { stable = output.length === settled;proc.resume() }, 150) - }, 150) - }) - proc.onExit(event => { - console.log(JSON.stringify({event,stable,exact:output===expected})) - proc.destroy() - }) - `) - expect(result).toEqual({ event: { exitCode: 0 }, stable: true, exact: true }) - }) - - it.skipIf(!existsSync('/bin/bash')).each([ - [false, 0], - [false, 100], - [true, 0], - [true, 100] - ] as const)( - 'stops foreground and background floods without losing output (resume failure: %s, signal gap: %sms)', - async (rejectFirstResume, stopSignalGapMs) => { - const result = await runTerminalScript(` - const expected = 16 * 1024 * 1024 - const {join} = require('node:path') - const {writeFileSync,existsSync} = require('node:fs') - const producer = join(args.cwd, 'producer.cjs') - const backgroundReady = join(args.cwd, 'background-ready') - const foregroundReady = join(args.cwd, 'foreground-ready') - const go = join(args.cwd, 'go') - const continueOutput = join(args.cwd, 'continue-output') - writeFileSync(producer, [ - 'const {writeFileSync,existsSync}=require("node:fs")', - 'writeFileSync(process.argv[2],"ready")', - 'const deadline=setTimeout(()=>process.exit(97),10000)', - 'const ready=setInterval(()=>{if(!existsSync(process.argv[3]))return;clearInterval(ready);process.stdout.write("x".repeat(65536));const continued=setInterval(()=>{if(!existsSync(process.argv[4]))return;clearInterval(continued);clearTimeout(deadline);let count=1;const timer=setInterval(()=>{process.stdout.write("x".repeat(65536));if(++count===128)clearInterval(timer)},1)},1)},1)' - ].join(';')) - const groups = new Set() - let bytes = 0, paused = false, settledBytes = 0, stable = false, verifying = false, rejectedResume = false - const proc = spawnBunPty({ - ...args, file:'/bin/bash', - args:['--noprofile','--norc','-i','-c','exec 2>/dev/null; "$ORCA_TEST_RUNTIME" "$ORCA_TEST_PRODUCER" "$ORCA_TEST_BACKGROUND_READY" "$ORCA_TEST_GO" "$ORCA_TEST_CONTINUE" & "$ORCA_TEST_RUNTIME" "$ORCA_TEST_PRODUCER" "$ORCA_TEST_FOREGROUND_READY" "$ORCA_TEST_GO" "$ORCA_TEST_CONTINUE"; wait'], - env:{...args.env,ORCA_TEST_RUNTIME:process.execPath,ORCA_TEST_PRODUCER:producer,ORCA_TEST_BACKGROUND_READY:backgroundReady,ORCA_TEST_FOREGROUND_READY:foregroundReady,ORCA_TEST_GO:go,ORCA_TEST_CONTINUE:continueOutput} - },{signalProcessGroup:(pgid,signal)=>{ - if (signal === 'SIGCONT' && ${rejectFirstResume} && !rejectedResume) { - rejectedResume = true - throw Object.assign(new Error('transient resume failure'), {code:'EPERM'}) - } - process.kill(-pgid,signal) - if (signal === 'SIGSTOP') { - groups.add(pgid) - // Give Bash time to react between signals; stopping its jobs first can end its wait. - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ${stopSignalGapMs}) - } - if (groups.size < 3 || verifying) return - verifying = true - writeFileSync(continueOutput, 'continue') - setTimeout(() => { - settledBytes = bytes - setTimeout(() => { stable = bytes === settledBytes;proc.resume() }, 150) - },150) - }}) - const ready = setInterval(() => { - if (!existsSync(backgroundReady) || !existsSync(foregroundReady)) return - clearInterval(ready) - writeFileSync(go, 'go') - }, 5) - let beats = 0 - const heartbeat = setInterval(() => beats++, 5) - proc.onData(data => { - bytes += data.length - // Drain both initial writes before measuring whether stopped producers emit more. - if (!paused && bytes === 2 * 65536) { - paused = true - proc.pause() - } - }) - proc.onExit(event => { - clearInterval(ready) - clearInterval(heartbeat) - console.log(JSON.stringify({event,stable,exact:bytes===expected,pausedBeforeExit:settledBytes10,jobControlGroups:groups.size>=3,rejectedResume})) - proc.destroy() - }) - `) - expect(result).toEqual({ - event: { exitCode: 0 }, - stable: true, - exact: true, - pausedBeforeExit: true, - responsive: true, - jobControlGroups: true, - rejectedResume: rejectFirstResume - }) - } - ) - } -) - -describe.skipIf(!existsSync(runtimePath) || process.platform !== 'win32')( - 'native Windows Bun terminal', - () => { - it('falls back after actual shell spawn rejection and cleans each private launch directory', async () => { - const result = await runTerminalScript(` - const {spawnNativeDaemonPty} = require(${JSON.stringify(join(__dirname, 'native-pty-spawn.ts'))}) - const {createWindowsBunPtyLaunch} = require(${JSON.stringify(join(__dirname, 'windows-bun-pty-launch.ts'))}) - const {existsSync} = require('node:fs') - const {dirname,join} = require('node:path') - const directories = [] - const attempts = [join(args.cwd,'missing-pwsh.exe'),join(args.cwd,'missing-powershell.exe'),process.execPath].map(shellPath=>({ - shellPath,shellArgs:['-e','process.exitCode=17'],effectiveCwd:args.cwd,validationCwd:args.cwd,startupCommandDeliveredInShellArgs:true - })) - spawnNativeDaemonPty({ - shellPath:attempts[0].shellPath,shellArgs:attempts[0].shellArgs,spawnCwd:args.cwd, - env:args.env,cols:80,rows:24,windowsFallbackAttempts:attempts - }, {canUseBunPty:()=>true, spawnBunPty:options=>spawnBunPty(options, { - createWindowsLaunch:launchArgs=>{ - const launch = createWindowsBunPtyLaunch(launchArgs, { - runtimePath:process.execPath,workerPath:${JSON.stringify(join(__dirname, 'windows-bun-pty-gate-entry.ts'))} - }) - directories.push(dirname(launch.command.at(-1))) - return launch - } - })}).then(({process:proc,shellPath})=>{ - proc.onExit(event=>{ - console.log(JSON.stringify({event,fallback:shellPath===process.execPath,attempts:directories.length,cleaned:directories.every(path=>!existsSync(path))})) - proc.destroy() - }) - }).catch(error=>{console.error(error);process.exitCode=1}) - `) - expect(result).toEqual({ - event: { exitCode: 17 }, - fallback: true, - attempts: 3, - cleaned: true - }) - }, 35_000) - - it('enumerates and suspends a native job with more than 64 processes', async () => { - const result = await runTerminalScript(` - const {createWindowsBunPtyLaunch} = require(${JSON.stringify(join(__dirname, 'windows-bun-pty-launch.ts'))}) - const script = 'for(let i=0;i<65;i++)Bun.spawn([process.execPath,"-e","setInterval(()=>{},1000)"],{stdin:"ignore",stdout:"ignore",stderr:"ignore"});setInterval(()=>console.log("tick"),10)' - const proc = spawnBunPty({...args,args:['-e',script]}, { - createWindowsLaunch:launch => createWindowsBunPtyLaunch(launch, { - runtimePath:process.execPath,workerPath:${JSON.stringify(join(__dirname, 'windows-bun-pty-gate-entry.ts'))} - }) - }) - let bytes=0,started=false,evidence - proc.onData(data=>{ - bytes+=data.length - if(started || !data.includes('tick'))return - const members=proc.listOwnedProcessIds() - if(!members || members.length<67)return - started=true - proc.pause() - setTimeout(()=>{ - const pausedBytes=bytes - setTimeout(()=>{ - const stopped=bytes===pausedBytes - proc.resume() - setTimeout(()=>{ - evidence={members:members.length,stopped,resumed:bytes>pausedBytes} - proc.kill() - },150) - },150) - },150) - }) - proc.onExit(()=>{ - console.log(JSON.stringify(evidence)) - proc.destroy() - }) - `) - expect(result).toEqual({ members: 67, stopped: true, resumed: true }) - }, 35_000) - - it('opens ConPTY without IPC and identifies the shell inside its job', async () => { - const result = await runTerminalScript(` - const {createWindowsBunPtyLaunch} = require(${JSON.stringify(join(__dirname, 'windows-bun-pty-launch.ts'))}) - const proc = spawnBunPty({...args,args:['-e','console.log("ready");setInterval(()=>{},1000)']}, { - createWindowsLaunch:launch => createWindowsBunPtyLaunch(launch, { - runtimePath:process.execPath,workerPath:${JSON.stringify(join(__dirname, 'windows-bun-pty-gate-entry.ts'))} - }) - }) - let output = '', evidence - proc.onData(data => output += data) - const timer = setInterval(() => { - const shell = proc.shellProcessId - const members = proc.listOwnedProcessIds() - if (!output.includes('ready') || !shell || !members?.includes(shell)) return - clearInterval(timer) - evidence = {distinctShell:shell!==proc.pid, gateOwned:members.includes(proc.pid), shellOwned:true} - proc.kill() - }, 10) - proc.onExit(event => { - clearInterval(timer) - console.log(JSON.stringify({evidence, exited:event.exitCode!==undefined})) - proc.destroy() - }) - `) - expect(result).toEqual({ - evidence: { distinctShell: true, gateOwned: true, shellOwned: true }, - exited: true - }) - }) - } -) diff --git a/src/main/daemon/pty-subprocess/bun-pty-process.test.ts b/src/main/daemon/pty-subprocess/bun-pty-process.test.ts deleted file mode 100644 index 6726524f53d3..000000000000 --- a/src/main/daemon/pty-subprocess/bun-pty-process.test.ts +++ /dev/null @@ -1,652 +0,0 @@ -import { constants } from 'node:os' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { canUseBunPty, spawnBunPty } from './bun-pty-process' -import type { BunRuntime, BunTerminalOptions } from './bun-pty-process-contract' -import { readWindowsPtyJobProcessIds } from '../../providers/windows-pty-job-membership' -import * as posixPtyGroups from '../../pty/posix-pty-process-groups' - -type FakeTerminal = { - closed: boolean - write(data: string | ArrayBufferView): number - resize(cols: number, rows: number): void - close(): void -} - -let testRuntime: NonNullable[1]>['runtime'] - -function createBunHarness({ closeImmediately = true } = {}) { - let resolveExit: (code: number) => void = () => {} - let windowsTerminalOptions: BunTerminalOptions | undefined - const terminal: FakeTerminal = { - closed: false, - write: vi.fn(() => 1), - resize: vi.fn(), - close: vi.fn(function (this: FakeTerminal) { - this.closed = true - if (closeImmediately) { - windowsTerminalOptions?.exit?.(terminal, 0, null) - } - }) - } - const processHandle = { - pid: 4321, - terminal, - kill: vi.fn(), - exited: new Promise((resolve) => { - resolveExit = resolve - }) - } - const spawn = vi.fn( - (_command: string[], _options: Parameters[1]) => processHandle - ) - testRuntime = { - Terminal: class { - closed = false - write = terminal.write - resize = terminal.resize - close = terminal.close - constructor(options: BunTerminalOptions) { - windowsTerminalOptions = options - return terminal - } - }, - spawn - } - const emitData = (data: Uint8Array): void => { - const options = spawn.mock.calls[0]?.[1] - const callbacks = - windowsTerminalOptions ?? - (options && 'data' in options.terminal ? options.terminal : undefined) - if (!callbacks) { - throw new Error('missing terminal callbacks') - } - callbacks.data(terminal, data) - } - return { - processHandle, - resolveExit, - spawn, - terminal, - emitData, - finishTerminal: () => windowsTerminalOptions?.exit?.(terminal, 0, null) - } -} - -function spawn(deps?: Parameters[1]) { - return spawnBunPty( - { - file: '/bin/sh', - args: ['-l'], - cwd: '/tmp', - env: { TERM: 'xterm-256color' }, - cols: 80, - rows: 24 - }, - { platform: 'linux', runtime: testRuntime, ...deps } - ) -} - -afterEach(() => { - vi.useRealTimers() - vi.restoreAllMocks() - testRuntime = undefined -}) - -describe('Bun.Terminal PTY adapter', () => { - it('cancels a pending ownership lookup on natural exit without delivering a late stop', async () => { - const harness = createBunHarness() - let finishRead: (table: string) => void = () => {} - const read = vi.spyOn(posixPtyGroups, 'readPosixPtyProcessTable').mockImplementation( - () => - new Promise((resolve) => { - finishRead = resolve - }) - ) - const signalProcessGroup = vi.fn() - const proc = spawn({ signalProcessGroup }) - proc.pause() - await new Promise((resolve) => setImmediate(resolve)) - const signal = read.mock.calls[0][1] - expect(signal?.aborted).toBe(false) - harness.resolveExit(0) - await harness.processHandle.exited - expect(signal?.aborted).toBe(true) - finishRead('4321 4321 pts/test T\n4322 4322 pts/test') - await new Promise((resolve) => setImmediate(resolve)) - expect(signalProcessGroup).not.toHaveBeenCalled() - expect(harness.processHandle.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]]) - }) - - it('cancels a queued resume retry immediately on natural exit', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const harness = createBunHarness() - const read = vi - .spyOn(posixPtyGroups, 'readPosixPtyProcessTable') - .mockResolvedValueOnce('4321 4321 pts/test T\n4322 4322 pts/test') - .mockRejectedValueOnce(new Error('temporary ps failure')) - const signalProcessGroup = vi.fn() - const proc = spawn({ signalProcessGroup }) - proc.pause() - await new Promise((resolve) => setImmediate(resolve)) - proc.resume() - await new Promise((resolve) => setImmediate(resolve)) - expect(vi.getTimerCount()).toBe(1) - harness.resolveExit(0) - await harness.processHandle.exited - expect(vi.getTimerCount()).toBe(0) - await vi.advanceTimersByTimeAsync(5_000) - expect(read).toHaveBeenCalledTimes(2) - expect(signalProcessGroup.mock.calls).toEqual([ - [4321, 'SIGSTOP'], - [4322, 'SIGSTOP'] - ]) - expect(harness.processHandle.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]]) - }) - - it('exposes initial and successfully applied dimensions for terminal inspection', () => { - const harness = createBunHarness() - const proc = spawn() - expect({ cols: proc.cols, rows: proc.rows }).toEqual({ cols: 80, rows: 24 }) - proc.resize(103, 37) - expect(harness.terminal.resize).toHaveBeenCalledWith(103, 37) - expect({ cols: proc.cols, rows: proc.rows }).toEqual({ cols: 103, rows: 37 }) - }) - - it.each(['closed', 'exited', 'failed'] as const)( - 'retains last applied dimensions when resize is %s', - async (reason) => { - const harness = createBunHarness() - const proc = spawn() - proc.resize(103, 37) - if (reason === 'closed') { - harness.terminal.closed = true - } - if (reason === 'exited') { - harness.resolveExit(0) - await harness.processHandle.exited - } - if (reason === 'failed') { - vi.mocked(harness.terminal.resize).mockImplementationOnce(() => { - throw new Error('closed') - }) - } - proc.resize(120, 40) - expect({ cols: proc.cols, rows: proc.rows }).toEqual({ cols: 103, rows: 37 }) - } - ) - - it('requires Bun.Terminal as well as Bun.spawn', () => { - const spawn = vi.fn() - expect(canUseBunPty({ spawn })).toBe(false) - expect(canUseBunPty({ Terminal: class {}, spawn })).toBe(true) - }) - - it('streams split UTF-8 and reports exit to current and late listeners', async () => { - const harness = createBunHarness() - const proc = spawn() - const onData = vi.fn() - const onExit = vi.fn() - proc.onData(onData) - proc.onExit(onExit) - - const bytes = new TextEncoder().encode('⌘状') - harness.emitData(bytes.slice(0, 2)) - expect(onData).not.toHaveBeenCalled() - harness.emitData(bytes.slice(2)) - expect(onData).toHaveBeenCalledWith('⌘状') - - harness.resolveExit(7) - await harness.processHandle.exited - await Promise.resolve() - expect(onExit).toHaveBeenCalledWith({ exitCode: 7 }) - - const lateExit = vi.fn() - proc.onExit(lateExit) - expect(lateExit).toHaveBeenCalledWith({ exitCode: 7 }) - }) - - it('preserves output arriving before the first data listener', () => { - const harness = createBunHarness() - const proc = spawn() - harness.emitData(new TextEncoder().encode('startup output')) - const listener = vi.fn() - proc.onData(listener) - expect(listener).toHaveBeenCalledWith('startup output') - }) - - it('preserves signal termination and never signals the exited handle during disposal', async () => { - const harness = createBunHarness() - const proc = spawn() - Object.assign(harness.processHandle, { signalCode: 'SIGTERM' }) - harness.resolveExit(143) - await harness.processHandle.exited - await Promise.resolve() - const listener = vi.fn() - proc.onExit(listener) - proc.destroy() - expect(listener).toHaveBeenCalledWith({ exitCode: 143, signal: 15 }) - expect(harness.processHandle.kill).not.toHaveBeenCalled() - expect(harness.terminal.close).toHaveBeenCalledOnce() - }) - - it('disposes data and exit listeners without retaining them', async () => { - const harness = createBunHarness() - const proc = spawn() - const onData = vi.fn() - const onExit = vi.fn() - const dataSubscription = proc.onData(onData) - const exitSubscription = proc.onExit(onExit) - - dataSubscription.dispose() - exitSubscription.dispose() - harness.emitData(new TextEncoder().encode('ignored')) - harness.resolveExit(0) - await harness.processHandle.exited - await Promise.resolve() - - expect(onData).not.toHaveBeenCalled() - expect(onExit).not.toHaveBeenCalled() - }) - - it.each(['darwin', 'linux'] as const)( - 'forwards input, resize, hangup, explicit signals, and destroy on %s', - (platform) => { - const harness = createBunHarness() - const proc = spawn({ platform }) - - proc.write('hello') - proc.resize(120, 40) - proc.kill() - proc.kill('SIGTERM') - proc.kill('SIGINT') - proc.kill('SIGKILL') - proc.destroy() - - expect(harness.terminal.write).toHaveBeenCalledWith('hello') - expect(harness.terminal.resize).toHaveBeenCalledWith(120, 40) - expect(harness.processHandle.kill.mock.calls).toEqual([ - ['SIGHUP'], - ['SIGTERM'], - ['SIGINT'], - ['SIGKILL'], - ['SIGHUP'] - ]) - expect(harness.terminal.close).toHaveBeenCalledOnce() - } - ) - - it('destroys a still-running process even if its terminal has already closed', () => { - const harness = createBunHarness() - const proc = spawn() - harness.terminal.closed = true - proc.destroy() - expect(harness.processHandle.kill).toHaveBeenCalledWith('SIGHUP') - expect(harness.terminal.close).not.toHaveBeenCalled() - }) - - it('contains a native terminal write failure and suppresses later writes', () => { - const harness = createBunHarness() - harness.terminal.write = vi.fn(() => { - throw new Error('terminal closed') - }) - const proc = spawn() - - expect(() => proc.write('first')).not.toThrow() - proc.write('second') - - expect(harness.terminal.write).toHaveBeenCalledOnce() - }) - - it('contains a native terminal resize failure and suppresses later resizes', () => { - const harness = createBunHarness() - harness.terminal.resize = vi.fn(() => { - throw new Error('terminal closed') - }) - const proc = spawn() - - expect(() => proc.resize(120, 40)).not.toThrow() - proc.resize(100, 30) - - expect(harness.terminal.resize).toHaveBeenCalledOnce() - }) - - it('pauses and resumes the POSIX producer process group once per transition', async () => { - createBunHarness() - const signalProcessGroup = vi.fn() - const proc = spawn({ - readProcessTable: () => ' 4321 4321 pts/test T\n 4322 4322 pts/test', - signalProcessGroup - }) - - proc.pause() - proc.pause() - await vi.waitFor(() => expect(signalProcessGroup).toHaveBeenCalledTimes(2)) - proc.resume() - proc.resume() - await vi.waitFor(() => expect(signalProcessGroup).toHaveBeenCalledTimes(4)) - - expect(signalProcessGroup.mock.calls).toEqual([ - [4321, 'SIGSTOP'], - [4322, 'SIGSTOP'], - [4322, 'SIGCONT'], - [4321, 'SIGCONT'] - ]) - }) - - it('resumes a paused process group before graceful shutdown', async () => { - const harness = createBunHarness() - const signalProcessGroup = vi.fn() - const proc = spawn({ - readProcessTable: () => ' 4321 4321 pts/test T\n 4322 4322 pts/test', - signalProcessGroup - }) - - proc.pause() - await vi.waitFor(() => expect(signalProcessGroup).toHaveBeenCalledTimes(2)) - proc.kill() - - expect(signalProcessGroup.mock.calls).toEqual([ - [4321, 'SIGSTOP'], - [4322, 'SIGSTOP'], - [4322, 'SIGCONT'], - [4321, 'SIGCONT'] - ]) - expect(harness.processHandle.kill).toHaveBeenCalledWith('SIGHUP') - }) - - it('falls back to Bun process signals when group signaling is unavailable', async () => { - const harness = createBunHarness() - vi.spyOn(process, 'kill').mockImplementation(() => { - throw Object.assign(new Error('not supported'), { code: 'EINVAL' }) - }) - const proc = spawn({ readProcessTable: () => '' }) - - proc.pause() - await vi.waitFor(() => - expect(harness.processHandle.kill).toHaveBeenCalledWith(constants.signals.SIGSTOP) - ) - proc.resume() - await vi.waitFor(() => - expect(harness.processHandle.kill).toHaveBeenCalledWith(constants.signals.SIGCONT) - ) - - expect(harness.processHandle.kill.mock.calls).toEqual([ - [constants.signals.SIGSTOP], - [constants.signals.SIGCONT] - ]) - }) - - it('gates a Windows shell behind exact job ownership and exposes owned capabilities', async () => { - const harness = createBunHarness({ closeImmediately: false }) - const assignHostJob = vi.fn(() => true) - const release = vi.fn() - const dispose = vi.fn() - const waitForSpawn = vi.fn(async () => {}) - let reportedShellPid: number | undefined - const job = { - listProcessIds: vi.fn(() => [4321, 4322]), - pause: vi.fn(() => true), - resume: vi.fn(() => true), - terminate: vi.fn(() => 'terminated' as const), - close: vi.fn() - } - const createJob = vi.fn(() => job) - const createWindowsLaunch = vi.fn(() => ({ - command: ['cmd.exe', '/d /c launch.cmd'], - clearCommand: ['cmd.exe', '/d /c clear.cmd'], - env: { TERM: 'xterm-256color', ORCA_BUN_PTY_JOB_GATE: 'gate' }, - windowsVerbatimArguments: true as const, - release, - dispose, - waitForSpawn, - readShellProcessId: () => reportedShellPid - })) - const proc = spawn({ - platform: 'win32', - assignHostJob, - createJob, - createWindowsLaunch - }) - - expect(harness.spawn.mock.calls[0]?.[0]).toEqual(['cmd.exe', '/d /c launch.cmd']) - expect(harness.spawn.mock.calls[0]?.[1]).toMatchObject({ - windowsVerbatimArguments: true, - env: { ORCA_BUN_PTY_JOB_GATE: 'gate' }, - terminal: harness.terminal - }) - expect(assignHostJob.mock.invocationCallOrder[0]).toBeLessThan( - harness.spawn.mock.invocationCallOrder[0] - ) - expect(createJob).toHaveBeenCalledWith(4321) - expect(createJob.mock.invocationCallOrder[0]).toBeLessThan(release.mock.invocationCallOrder[0]) - await proc.waitForSpawn?.() - expect(waitForSpawn).toHaveBeenCalledWith(harness.processHandle.exited) - - proc.pause() - proc.pause() - proc.resume() - proc.resume() - expect(job.pause).toHaveBeenCalledOnce() - expect(job.resume).toHaveBeenCalledOnce() - expect(proc.jobRootProcessIsWrapper).toBe(true) - expect(readWindowsPtyJobProcessIds(proc)).toBeNull() - expect(harness.spawn.mock.calls[0]?.[1]).not.toHaveProperty('ipc') - reportedShellPid = 4322 - expect(proc.shellProcessId).toBe(4322) - expect(readWindowsPtyJobProcessIds(proc)).toEqual(new Set([4322])) - job.listProcessIds.mockReturnValueOnce([4321, 4323]) - expect(readWindowsPtyJobProcessIds(proc)).toBeNull() - expect(proc.shellProcessId).toBe(4322) - expect(proc.listOwnedProcessIds?.()).toEqual([4321, 4322]) - expect(proc.terminateOwnedTree?.()).toBe('terminated') - - job.terminate.mockClear() - proc.signalProcess?.('SIGINT') - expect(job.terminate).toHaveBeenCalledOnce() - expect(harness.processHandle.kill).not.toHaveBeenCalled() - - proc.clear() - proc.clear() - expect(harness.spawn.mock.calls[1]?.[0]).toEqual(['cmd.exe', '/d /c clear.cmd']) - expect(harness.spawn.mock.calls[1]?.[1]).toMatchObject({ - terminal: harness.terminal, - windowsVerbatimArguments: true - }) - expect(harness.spawn).toHaveBeenCalledTimes(2) - - const lastOutput = vi.fn() - const onExit = vi.fn() - proc.onData(lastOutput) - proc.onExit(onExit) - harness.resolveExit(0) - await harness.processHandle.exited - await Promise.resolve() - expect(onExit).not.toHaveBeenCalled() - expect(job.close).not.toHaveBeenCalled() - harness.emitData(new TextEncoder().encode('final ConPTY frame')) - harness.finishTerminal() - expect(lastOutput).toHaveBeenCalledWith('final ConPTY frame') - expect(onExit).toHaveBeenCalledOnce() - expect(job.close).toHaveBeenCalledOnce() - expect(dispose).toHaveBeenCalledOnce() - expect(job.resume).toHaveBeenCalledOnce() - job.resume.mockImplementation(() => { - throw new Error('job already closed') - }) - expect(() => { - proc.pause() - proc.resume() - proc.kill() - proc.destroy() - }).not.toThrow() - expect(job.resume).toHaveBeenCalledOnce() - }) - - it('does not release a Windows gate without exact job ownership', async () => { - const harness = createBunHarness() - const release = vi.fn() - const dispose = vi.fn() - - expect(() => - spawn({ - platform: 'win32', - assignHostJob: () => true, - createJob: () => null, - createWindowsLaunch: () => ({ - command: ['cmd.exe', '/d /c launch.cmd'], - clearCommand: ['cmd.exe', '/d /c clear.cmd'], - env: {}, - windowsVerbatimArguments: true, - waitForSpawn: async () => {}, - readShellProcessId: () => undefined, - release, - dispose - }) - }) - ).toThrow('Windows Bun PTY job ownership is unavailable') - - expect(release).not.toHaveBeenCalled() - expect(harness.processHandle.kill).toHaveBeenCalledWith('SIGTERM') - expect(harness.terminal.close).toHaveBeenCalledOnce() - expect(dispose).toHaveBeenCalledOnce() - harness.resolveExit(1) - await harness.processHandle.exited - expect(dispose).toHaveBeenCalledTimes(2) - }) - - it('does not spawn a Windows PTY without host crash ownership', () => { - const harness = createBunHarness() - const createWindowsLaunch = vi.fn() - - expect(() => - spawn({ - platform: 'win32', - assignHostJob: () => false, - createWindowsLaunch - }) - ).toThrow('Windows Bun PTY host crash ownership is unavailable') - - expect(createWindowsLaunch).not.toHaveBeenCalled() - expect(harness.spawn).not.toHaveBeenCalled() - }) - - it('preserves a Windows PTY after a failed suspension and allows a retry', () => { - const harness = createBunHarness() - const job = { - listProcessIds: vi.fn(() => [4321]), - pause: vi.fn(() => true).mockReturnValueOnce(false), - resume: vi.fn(() => true), - terminate: vi.fn(() => 'terminated' as const), - close: vi.fn() - } - const proc = spawn({ - platform: 'win32', - assignHostJob: () => true, - createJob: () => job, - createWindowsLaunch: () => ({ - command: ['cmd.exe', '/d /c launch.cmd'], - clearCommand: ['cmd.exe', '/d /c clear.cmd'], - env: {}, - windowsVerbatimArguments: true, - waitForSpawn: async () => {}, - readShellProcessId: () => undefined, - release: vi.fn(), - dispose: vi.fn() - }) - }) - - proc.pause() - proc.write('still usable') - proc.pause() - proc.resume() - - expect(job.pause).toHaveBeenCalledTimes(2) - expect(job.resume).toHaveBeenCalledOnce() - expect(job.terminate).not.toHaveBeenCalled() - expect(harness.terminal.close).not.toHaveBeenCalled() - expect(harness.terminal.write).toHaveBeenCalledWith('still usable') - - proc.kill() - proc.kill('SIGKILL') - proc.destroy() - expect(harness.processHandle.kill.mock.calls).toEqual([['SIGTERM'], ['SIGKILL'], ['SIGTERM']]) - expect(job.terminate).toHaveBeenCalledTimes(3) - expect(harness.terminal.close).toHaveBeenCalledOnce() - }) - - it('delivers Windows exit after cleanup failures', async () => { - const harness = createBunHarness() - const cleanupError = new Error('job close failed') - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const proc = spawn({ - platform: 'win32', - assignHostJob: () => true, - createJob: () => ({ - listProcessIds: vi.fn(() => []), - pause: vi.fn(() => true), - resume: vi.fn(() => true), - terminate: vi.fn(() => 'terminated' as const), - close: vi.fn(() => { - throw cleanupError - }) - }), - createWindowsLaunch: () => ({ - command: ['cmd.exe', '/d /c launch.cmd'], - clearCommand: ['cmd.exe', '/d /c clear.cmd'], - env: {}, - windowsVerbatimArguments: true, - waitForSpawn: async () => {}, - readShellProcessId: () => undefined, - release: vi.fn(), - dispose: vi.fn() - }) - }) - const onExit = vi.fn() - proc.onExit(onExit) - - harness.resolveExit(9) - await harness.processHandle.exited - await Promise.resolve() - - expect(onExit).toHaveBeenCalledWith({ exitCode: 9 }) - expect(warn).toHaveBeenCalledWith('[daemon/pty] PTY cleanup failed:', cleanupError) - }) - - it('terminates and closes Windows job state when gate release fails', () => { - const harness = createBunHarness() - const dispose = vi.fn() - const job = { - listProcessIds: vi.fn(() => [4321]), - pause: vi.fn(() => true), - resume: vi.fn(() => true), - terminate: vi.fn(() => 'terminated' as const), - close: vi.fn() - } - - expect(() => - spawn({ - platform: 'win32', - assignHostJob: () => true, - createJob: () => job, - createWindowsLaunch: () => ({ - command: ['cmd.exe', '/d /c launch.cmd'], - clearCommand: ['cmd.exe', '/d /c clear.cmd'], - env: {}, - windowsVerbatimArguments: true, - waitForSpawn: async () => {}, - readShellProcessId: () => undefined, - release() { - throw new Error('gate release failed') - }, - dispose - }) - }) - ).toThrow('gate release failed') - - expect(job.terminate).toHaveBeenCalledOnce() - expect(job.close).toHaveBeenCalledOnce() - expect(harness.processHandle.kill).toHaveBeenCalledWith('SIGTERM') - expect(harness.terminal.close).toHaveBeenCalledOnce() - expect(dispose).toHaveBeenCalledOnce() - }) -}) diff --git a/src/main/daemon/pty-subprocess/bun-pty-process.ts b/src/main/daemon/pty-subprocess/bun-pty-process.ts deleted file mode 100644 index b8d68b65ad47..000000000000 --- a/src/main/daemon/pty-subprocess/bun-pty-process.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { canUseBunPty } from './bun-pty-process-capabilities' -export { spawnBunPty } from './bun-pty-process-runtime' diff --git a/src/main/daemon/pty-subprocess/native-pty-spawn-bun.test.ts b/src/main/daemon/pty-subprocess/native-pty-spawn-bun.test.ts deleted file mode 100644 index 26a3fcda7dc7..000000000000 --- a/src/main/daemon/pty-subprocess/native-pty-spawn-bun.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -const { nodePtyFactory, wrapShellSpawnMock } = vi.hoisted(() => ({ - nodePtyFactory: vi.fn(() => ({ spawn: vi.fn() })), - wrapShellSpawnMock: vi.fn((file: string, args: string[]) => ({ file, args })) -})) - -vi.mock('node-pty', nodePtyFactory) -vi.mock('../../providers/macos-tcc-login-shell', () => ({ - hostReportsChildExitStatus: (file: string) => file !== '/usr/bin/login', - wrapShellSpawnForMacosTccAttribution: wrapShellSpawnMock -})) - -import { spawnNativeDaemonPty } from './native-pty-spawn' - -describe('native PTY runtime selection', () => { - it('spawns with Bun.Terminal without loading node-pty', async () => { - const dispose = vi.fn() - const spawnBunPty = vi.fn(() => ({ - pid: 9876, - cols: 80, - rows: 24, - process: '/bin/zsh', - handleFlowControl: false, - onData: vi.fn(() => ({ dispose })), - onExit: vi.fn(() => ({ dispose })), - write: vi.fn(), - resize: vi.fn(), - clear: vi.fn(), - kill: vi.fn(), - destroy: vi.fn(), - pause: vi.fn(), - resume: vi.fn() - })) - - const result = await spawnNativeDaemonPty( - { - shellPath: '/bin/zsh', - shellArgs: ['-l'], - spawnCwd: '/tmp', - env: { TERM: 'xterm-256color' }, - cols: 80, - rows: 24, - windowsFallbackAttempts: [] - }, - { canUseBunPty: () => true, spawnBunPty } - ) - - expect(result.process.pid).toBe(9876) - expect(spawnBunPty).toHaveBeenCalledOnce() - expect(nodePtyFactory).not.toHaveBeenCalled() - }) - - it('applies the macOS login wrapper before selecting the Bun PTY runtime', async () => { - const platform = Object.getOwnPropertyDescriptor(process, 'platform') - const spawnBunPty = vi.fn(() => ({ - pid: 9877, - cols: 80, - rows: 24, - process: '/usr/bin/login', - handleFlowControl: false, - onData: vi.fn(() => ({ dispose: vi.fn() })), - onExit: vi.fn(() => ({ dispose: vi.fn() })), - write: vi.fn(), - resize: vi.fn(), - clear: vi.fn(), - kill: vi.fn(), - destroy: vi.fn(), - pause: vi.fn(), - resume: vi.fn() - })) - const onMacosTccSpawnStrategy = vi.fn() - wrapShellSpawnMock.mockReturnValueOnce({ - file: '/usr/bin/login', - args: ['-flpq', 'tester', '/bin/zsh', '-l'] - }) - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) - - try { - const result = await spawnNativeDaemonPty( - { - shellPath: '/bin/zsh', - shellArgs: ['-l'], - spawnCwd: '/tmp', - env: { TERM: 'xterm-256color' }, - cols: 80, - rows: 24, - windowsFallbackAttempts: [], - onMacosTccSpawnStrategy - }, - { canUseBunPty: () => true, spawnBunPty } - ) - - expect(result.process.pid).toBe(9877) - expect(spawnBunPty).toHaveBeenCalledWith( - expect.objectContaining({ - file: '/usr/bin/login', - args: ['-flpq', 'tester', '/bin/zsh', '-l'] - }) - ) - expect(result.reportsChildExitStatus).toBe(false) - expect(onMacosTccSpawnStrategy).toHaveBeenCalledWith('wrapped') - } finally { - if (platform) { - Object.defineProperty(process, 'platform', platform) - } - } - }) -}) diff --git a/src/main/daemon/pty-subprocess/native-pty-spawn-windows.test.ts b/src/main/daemon/pty-subprocess/native-pty-spawn-windows.test.ts index 8d6f7117d081..a63ea778b881 100644 --- a/src/main/daemon/pty-subprocess/native-pty-spawn-windows.test.ts +++ b/src/main/daemon/pty-subprocess/native-pty-spawn-windows.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const nodePty = vi.hoisted(() => ({ spawn: vi.fn() })) +vi.mock('node-pty', () => nodePty) +vi.mock('../../windows/windows-pty-job', () => ({ assignHostProcessToKillOnCloseJob: vi.fn() })) + import { spawnNativeDaemonPty } from './native-pty-spawn' -import { WindowsBunPtySpawnUnconfirmedError } from './windows-bun-pty-spawn-receipt' const attempts = ['pwsh.exe', 'powershell.exe', 'cmd.exe'].map((shellPath) => ({ shellPath, @@ -19,106 +23,47 @@ const args = { windowsFallbackAttempts: attempts } -function createProcess(waitForSpawn: () => Promise) { - return { - pid: 9876, - cols: 80, - rows: 24, - process: 'gate', - handleFlowControl: false, - onData: vi.fn(() => ({ dispose: vi.fn() })), - onExit: vi.fn(() => ({ dispose: vi.fn() })), - write: vi.fn(), - resize: vi.fn(), - clear: vi.fn(), - kill: vi.fn(), - destroy: vi.fn(), - pause: vi.fn(), - resume: vi.fn(), - waitForSpawn - } -} - -describe('Windows Bun shell fallback after gated spawn', () => { +describe('Windows shell fallback over node-pty ConPTY', () => { const platform = Object.getOwnPropertyDescriptor(process, 'platform')! beforeEach(() => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) vi.spyOn(console, 'warn').mockImplementation(() => {}) + nodePty.spawn.mockReset() }) afterEach(() => { Object.defineProperty(process, 'platform', platform) vi.restoreAllMocks() }) - it('walks both fallback shells when gate wrappers start but their actual shells fail', async () => { - const spawnBunPty = vi.fn(({ file }: { file: string }) => - createProcess(async () => { - await Promise.resolve() - if (file !== 'cmd.exe') { - throw new Error(`spawn ${file} EACCES`) - } - }) - ) - const result = await spawnNativeDaemonPty(args, { canUseBunPty: () => true, spawnBunPty }) - expect(spawnBunPty.mock.calls.map(([args]) => args.file)).toEqual([ + it('walks both fallback shells when ConPTY rejects the primary ones', async () => { + const proc = { pid: 9876 } + nodePty.spawn.mockImplementation((file: string) => { + if (file !== 'cmd.exe') { + throw new Error(`spawn ${file} EACCES`) + } + return proc + }) + const result = await spawnNativeDaemonPty(args) + expect(nodePty.spawn.mock.calls.map(([file]) => file)).toEqual([ 'pwsh.exe', 'powershell.exe', 'cmd.exe' ]) + expect(nodePty.spawn.mock.calls[2]![2]).toMatchObject({ useConptyDll: true }) + expect(result.process).toBe(proc) expect(result.shellPath).toBe('cmd.exe') expect(result.startupCommandDeliveredInShellArgs).toBe(true) - expect(spawnBunPty.mock.results[0]!.value.destroy).toHaveBeenCalledOnce() - expect(spawnBunPty.mock.results[1]!.value.destroy).toHaveBeenCalledOnce() - expect(spawnBunPty.mock.results[2]!.value.destroy).not.toHaveBeenCalled() }) - it('does not report a wrapper as a working shell before its actual spawn is confirmed', async () => { - let confirm!: () => void - const confirmation = new Promise((resolve) => { - confirm = resolve - }) - const finished = vi.fn() - const spawnBunPty = vi.fn(() => createProcess(() => confirmation)) - const result = spawnNativeDaemonPty(args, { canUseBunPty: () => true, spawnBunPty }).then( - finished - ) - await Promise.resolve() - expect(finished).not.toHaveBeenCalled() - confirm() - await result - expect(finished).toHaveBeenCalledOnce() - }) - - it('destroys an unconfirmed gate on cancellation without starting a fallback shell', async () => { + it('starts no fallback shell once the spawn is canceled', async () => { const controller = new AbortController() - const proc = createProcess(() => new Promise(() => {})) - const spawnBunPty = vi.fn(() => proc) - const result = spawnNativeDaemonPty( - { ...args, signal: controller.signal }, - { canUseBunPty: () => true, spawnBunPty } + nodePty.spawn.mockImplementation(() => { + controller.abort(new Error('spawn canceled')) + throw new Error('spawn pwsh.exe EACCES') + }) + await expect(spawnNativeDaemonPty({ ...args, signal: controller.signal })).rejects.toThrow( + 'spawn canceled' ) - controller.abort(new Error('spawn canceled')) - await expect(result).rejects.toThrow('spawn canceled') - expect(proc.destroy).toHaveBeenCalledOnce() - expect(spawnBunPty).toHaveBeenCalledOnce() + expect(nodePty.spawn).toHaveBeenCalledOnce() }) - - it.each([0, 1])( - 'stops at an ambiguous attempt %s to avoid running its startup command twice', - async (ambiguousIndex) => { - const spawnBunPty = vi.fn(({ file }: { file: string }) => - createProcess(async () => { - if (file === attempts[ambiguousIndex]!.shellPath) { - throw new WindowsBunPtySpawnUnconfirmedError('missing receipt') - } - throw new Error('spawn ENOENT') - }) - ) - await expect( - spawnNativeDaemonPty(args, { canUseBunPty: () => true, spawnBunPty }) - ).rejects.toBeInstanceOf(WindowsBunPtySpawnUnconfirmedError) - expect(spawnBunPty).toHaveBeenCalledTimes(ambiguousIndex + 1) - expect(spawnBunPty.mock.results.at(-1)!.value.destroy).toHaveBeenCalledOnce() - } - ) }) diff --git a/src/main/daemon/pty-subprocess/native-pty-spawn.ts b/src/main/daemon/pty-subprocess/native-pty-spawn.ts index e89e8a5aac42..f87e0d78cf06 100644 --- a/src/main/daemon/pty-subprocess/native-pty-spawn.ts +++ b/src/main/daemon/pty-subprocess/native-pty-spawn.ts @@ -1,5 +1,4 @@ import type * as pty from 'node-pty' -import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' import { hostReportsChildExitStatus, wrapShellSpawnForMacosTccAttribution @@ -7,9 +6,6 @@ import { import type { WindowsShellSpawnAttempt } from '../../providers/windows-shell-fallback-chain' import { assignHostProcessToKillOnCloseJob } from '../../windows/windows-pty-job' -import { canUseBunPty, spawnBunPty } from './bun-pty-process' -import { WindowsBunPtySpawnUnconfirmedError } from './windows-bun-pty-spawn-receipt' - async function loadNodePty(): Promise { return import('node-pty') } @@ -22,26 +18,18 @@ export type SpawnedDaemonPty = { reportsChildExitStatus: boolean } -type NativePtyRuntime = { - canUseBunPty: typeof canUseBunPty - spawnBunPty: typeof spawnBunPty -} - /** Walks the Windows PowerShell -> cmd.exe fallback chain when ConPTY rejects the primary shell. */ -export async function spawnNativeDaemonPty( - args: { - shellPath: string - shellArgs: string[] - spawnCwd: string - env: Record - cols: number - rows: number - windowsFallbackAttempts: WindowsShellSpawnAttempt[] - signal?: AbortSignal - onMacosTccSpawnStrategy?: (strategy: 'wrapped' | 'direct') => void - }, - runtime: NativePtyRuntime = { canUseBunPty, spawnBunPty } -): Promise { +export async function spawnNativeDaemonPty(args: { + shellPath: string + shellArgs: string[] + spawnCwd: string + env: Record + cols: number + rows: number + windowsFallbackAttempts: WindowsShellSpawnAttempt[] + signal?: AbortSignal + onMacosTccSpawnStrategy?: (strategy: 'wrapped' | 'direct') => void +}): Promise { let reportsChildExitStatus = true const spawnAt = async ( shellPath: string, @@ -50,32 +38,6 @@ export async function spawnNativeDaemonPty( ): Promise => { args.signal?.throwIfAborted() const wrapped = wrapShellSpawnForMacosTccAttribution(shellPath, shellArgs, args.env) - reportsChildExitStatus = hostReportsChildExitStatus(wrapped.file) - if (runtime.canUseBunPty()) { - const proc = runtime.spawnBunPty({ - file: wrapped.file, - args: wrapped.args, - cwd, - env: args.env, - cols: args.cols, - rows: args.rows - }) - try { - if (proc.waitForSpawn) { - await waitForPromiseWithSignal(proc.waitForSpawn(), args.signal) - } - args.signal?.throwIfAborted() - } catch (error) { - try { - proc.destroy() - } catch (cleanupError) { - console.warn('[daemon/pty] Failed shell launch cleanup failed:', cleanupError) - } - throw error - } - args.onMacosTccSpawnStrategy?.(wrapped.file === shellPath ? 'direct' : 'wrapped') - return proc - } const nodePty = await loadNodePty() // Why: children inherit job membership, so the host job must exist before the first Windows PTY. if (process.platform === 'win32') { @@ -105,7 +67,7 @@ export async function spawnNativeDaemonPty( } } catch (primaryErr) { args.signal?.throwIfAborted() - if (process.platform !== 'win32' || primaryErr instanceof WindowsBunPtySpawnUnconfirmedError) { + if (process.platform !== 'win32') { throw primaryErr } for (const attempt of args.windowsFallbackAttempts.slice(1)) { @@ -122,11 +84,8 @@ export async function spawnNativeDaemonPty( startupCommandDeliveredInShellArgs: attempt.startupCommandDeliveredInShellArgs, reportsChildExitStatus } - } catch (error) { + } catch { args.signal?.throwIfAborted() - if (error instanceof WindowsBunPtySpawnUnconfirmedError) { - throw error - } // This fallback shell also failed -- try the next link in the chain. } } diff --git a/src/main/daemon/pty-subprocess/spawn-preflight-bun.test.ts b/src/main/daemon/pty-subprocess/spawn-preflight-bun.test.ts deleted file mode 100644 index d40b30e2b324..000000000000 --- a/src/main/daemon/pty-subprocess/spawn-preflight-bun.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { afterEach, beforeEach, expect, it, vi } from 'vitest' - -const fixture = vi.hoisted((): { shellPid?: number } => ({})) -vi.mock('./bun-pty-process', () => ({ - canUseBunPty: () => true, - spawnBunPty: () => ({ - pid: 41, - get shellProcessId() { - return fixture.shellPid - }, - onExit(callback: (event: { exitCode: number }) => void) { - queueMicrotask(() => callback({ exitCode: 0 })) - return { dispose() {} } - } - }) -})) - -import { runPtySpawnHealthProbe } from './spawn-preflight' - -beforeEach(() => - vi.stubGlobal( - 'process', - Object.create(process, { - platform: { value: 'win32' } - }) - ) -) -afterEach(() => vi.unstubAllGlobals()) - -it.each([undefined, 41, 0])( - 'refuses successful gate exit without shell identity %s', - async (pid) => { - fixture.shellPid = pid - await expect(runPtySpawnHealthProbe()).rejects.toThrow('could not identify the Windows shell') - } -) - -it('accepts successful exit with the separate original shell identity', async () => { - fixture.shellPid = 42 - await expect(runPtySpawnHealthProbe()).resolves.toBeUndefined() -}) diff --git a/src/main/daemon/pty-subprocess/spawn-preflight-health-probe.test.ts b/src/main/daemon/pty-subprocess/spawn-preflight-health-probe.test.ts new file mode 100644 index 000000000000..5f673b8abc75 --- /dev/null +++ b/src/main/daemon/pty-subprocess/spawn-preflight-health-probe.test.ts @@ -0,0 +1,72 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const nodePty = vi.hoisted(() => ({ spawn: vi.fn() })) +vi.mock('node-pty', () => nodePty) + +import { runPtySpawnHealthProbe } from './spawn-preflight' + +function exitingPty(exitCode: number) { + let listener: ((event: { exitCode: number }) => void) | undefined + const proc = { + kill: vi.fn(), + onExit: vi.fn((callback: typeof listener) => { + listener = callback + queueMicrotask(() => listener?.({ exitCode })) + return { dispose: () => (listener = undefined) } + }) + } + nodePty.spawn.mockReturnValue(proc) + return proc +} + +describe('PTY spawn health probe teardown', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform')! + const previousUserDataPath = process.env.ORCA_USER_DATA_PATH + let userDataPath: string + + beforeEach(() => { + nodePty.spawn.mockReset() + userDataPath = mkdtempSync(join(tmpdir(), 'orca-pty-health-probe-')) + process.env.ORCA_USER_DATA_PATH = userDataPath + }) + afterEach(() => { + Object.defineProperty(process, 'platform', platform) + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + rmSync(userDataPath, { recursive: true, force: true }) + }) + + it('kills a cleanly exited Windows probe so its conout worker cannot hold the process open', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const proc = exitingPty(0) + + await expect(runPtySpawnHealthProbe()).resolves.toBeUndefined() + + expect(nodePty.spawn.mock.calls[0]![2]).toMatchObject({ useConptyDll: true }) + expect(proc.kill).toHaveBeenCalledOnce() + }) + + it('kills a Windows probe that exits non-zero', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const proc = exitingPty(1) + + await expect(runPtySpawnHealthProbe()).rejects.toThrow(/exited with code 1/) + expect(proc.kill).toHaveBeenCalledOnce() + }) + + it('leaves a cleanly exited POSIX probe alone', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + const proc = exitingPty(0) + + await expect(runPtySpawnHealthProbe()).resolves.toBeUndefined() + + expect(nodePty.spawn.mock.calls[0]![2]).not.toHaveProperty('useConptyDll') + expect(proc.kill).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/daemon/pty-subprocess/spawn-preflight.ts b/src/main/daemon/pty-subprocess/spawn-preflight.ts index 404f5b258f04..412adbc567f4 100644 --- a/src/main/daemon/pty-subprocess/spawn-preflight.ts +++ b/src/main/daemon/pty-subprocess/spawn-preflight.ts @@ -11,7 +11,6 @@ import { import { resolveSafePtyDefaultCwd } from '../../providers/pty-default-cwd' import { TerminalAttachCanceledError } from '../daemon-errors' import { DaemonProtocolError } from '../types' -import { canUseBunPty, spawnBunPty } from './bun-pty-process' const PTY_SPAWN_HEALTH_TIMEOUT_MS = 4_000 @@ -85,7 +84,7 @@ function preflightDaemonCwd(): void { } function preflightMacNodePtySpawnEnvironment(): void { - if (process.platform !== 'darwin' || canUseBunPty()) { + if (process.platform !== 'darwin') { return } let candidates: string[] @@ -125,9 +124,7 @@ export async function preflightPtySpawn(args: { sessionId: string signal?: AbortSignal }): Promise { - if (!canUseBunPty()) { - ensureNodePtySpawnHelperExecutable() - } + ensureNodePtySpawnHelperExecutable() preflightUnixPtySpawnEnvironment() try { if (process.platform === 'win32') { @@ -163,7 +160,6 @@ export function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: s } export async function runPtySpawnHealthProbe(): Promise { - const requiresShellIdentity = process.platform === 'win32' && canUseBunPty() const cwd = isExistingDirectory(process.env.ORCA_USER_DATA_PATH) ? process.env.ORCA_USER_DATA_PATH : resolveSafePtyDefaultCwd() @@ -179,15 +175,15 @@ export async function runPtySpawnHealthProbe(): Promise { env[key] = value } } - proc = canUseBunPty() - ? spawnBunPty({ ...command, cols: 2, rows: 1, cwd, env }) - : (await loadNodePty()).spawn(command.file, command.args, { - name: 'xterm-256color', - cols: 2, - rows: 1, - cwd, - env - }) + proc = (await loadNodePty()).spawn(command.file, command.args, { + name: 'xterm-256color', + cols: 2, + rows: 1, + cwd, + env, + // Qualify the bundled ConPTY the daemon spawns with (native-pty-spawn.ts), not the OS one. + ...(process.platform === 'win32' ? { useConptyDll: true } : {}) + }) } catch (err) { throw formatPtySpawnError(err, command.file, cwd) } @@ -202,7 +198,9 @@ export async function runPtySpawnHealthProbe(): Promise { settled = true clearTimeout(timer) exitDisposable?.dispose() - if (opts?.kill) { + // Windows keeps the conout worker thread and pseudoconsole until kill(), even after the + // shell exits; left alive, they hold the probing process open. + if (opts?.kill || process.platform === 'win32') { try { proc.kill() } catch { @@ -222,18 +220,7 @@ export async function runPtySpawnHealthProbe(): Promise { }, PTY_SPAWN_HEALTH_TIMEOUT_MS) exitDisposable = proc.onExit(({ exitCode }) => { if (exitCode === 0) { - const shellPid = 'shellProcessId' in proc ? proc.shellProcessId : undefined - if ( - requiresShellIdentity && - (typeof shellPid !== 'number' || - !Number.isSafeInteger(shellPid) || - shellPid <= 0 || - shellPid === proc.pid) - ) { - finish(new Error('PTY spawn health check could not identify the Windows shell')) - } else { - finish() - } + finish() } else { finish(new Error(`PTY spawn health check exited with code ${exitCode}`)) } @@ -242,12 +229,10 @@ export async function runPtySpawnHealthProbe(): Promise { } export function preflightPtySpawnHealth(): boolean { - if (process.platform === 'win32' && !canUseBunPty()) { + if (process.platform === 'win32') { return false } - if (!canUseBunPty()) { - ensureNodePtySpawnHelperExecutable() - } + ensureNodePtySpawnHelperExecutable() preflightUnixPtySpawnEnvironment() return true } diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-gate-entry.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-gate-entry.ts deleted file mode 100644 index 9c17da7fa18e..000000000000 --- a/src/main/daemon/pty-subprocess/windows-bun-pty-gate-entry.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { unlinkSync } from 'node:fs' -import { readWindowsBunPtyGateRequest, runWindowsBunPtyGate } from './windows-bun-pty-gate' - -async function main(): Promise { - const requestPath = process.argv[2] - if (!requestPath) { - throw new Error('Windows PTY gate request path is required') - } - const request = readWindowsBunPtyGateRequest(requestPath) - // Arguments can contain agent prompts; do not retain them for the shell's lifetime. - unlinkSync(requestPath) - process.exitCode = await runWindowsBunPtyGate(request) -} - -void main().catch((error: unknown) => { - console.error( - '[pty] Windows job gate failed:', - error instanceof Error ? error.message : String(error) - ) - process.exitCode = 1 -}) diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-gate.integration.test.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-gate.integration.test.ts deleted file mode 100644 index bb1c3d88a478..000000000000 --- a/src/main/daemon/pty-subprocess/windows-bun-pty-gate.integration.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { build } from 'esbuild' -import { existsSync, mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' -import { describe, expect, it } from 'vitest' -import { runProcess, runProcessSync } from '../../../shared/child-process/run-process' -import { orcadBunRuntimeFilename } from '../../../shared/orcad-artifacts' -import { ORCAD_BUN_VERSION } from '../../../shared/orcad-bun-runtime' -import { createWindowsBunPtyLaunch } from './windows-bun-pty-launch' - -const runtimePath = - process.env.BUN_EXECUTABLE ?? - resolve(__dirname, '../../../../out/orcad', orcadBunRuntimeFilename(process.platform)) -const available = existsSync(runtimePath) - -describe.skipIf(!available)('bundled Windows job gate under Bun', () => { - it('executes the worker with real Bun flags and preserves long executable argv', async () => { - expect(runProcessSync({ program: runtimePath, args: ['--version'] }).stdout.trim()).toBe( - ORCAD_BUN_VERSION - ) - const directory = mkdtempSync(join(tmpdir(), 'orca-gate-contract-')) - const workerPath = join(directory, 'windows-bun-pty-gate-entry.js') - try { - await build({ - entryPoints: [join(__dirname, 'windows-bun-pty-gate-entry.ts')], - bundle: true, - platform: 'node', - format: 'cjs', - outfile: workerPath, - logLevel: 'silent' - }) - const argv = ['x'.repeat(16000), 'a b', 'quote"', '%value%&!', '状態', ''] - const env = Object.fromEntries( - Object.entries(process.env).filter( - (entry): entry is [string, string] => entry[1] !== undefined - ) - ) - const launch = createWindowsBunPtyLaunch( - { - file: runtimePath, - args: ['-e', 'console.log(JSON.stringify(process.argv.slice(1)))', ...argv], - cwd: directory, - env - }, - { runtimePath, workerPath } - ) - try { - launch.release() - const result = await runProcess({ - program: launch.command[0]!, - args: launch.command.slice(1), - cwd: directory, - env: launch.env, - timeoutMs: 10_000 - }) - expect(result.timedOut).toBe(false) - expect(result.code, result.stderr).toBe(0) - expect(JSON.parse(result.stdout)).toEqual(argv) - await expect(launch.waitForSpawn(Promise.resolve(result.code!))).resolves.toBeUndefined() - expect(existsSync(launch.command.at(-1)!)).toBe(false) - } finally { - launch.dispose() - } - const failedLaunch = createWindowsBunPtyLaunch( - { file: join(directory, 'missing-shell.exe'), args: [], cwd: directory, env }, - { runtimePath, workerPath } - ) - try { - failedLaunch.release() - const result = await runProcess({ - program: failedLaunch.command[0]!, - args: failedLaunch.command.slice(1), - cwd: directory, - env: failedLaunch.env, - timeoutMs: 10_000 - }) - expect(result.timedOut).toBe(false) - expect(result.code).toBe(1) - await expect(failedLaunch.waitForSpawn(Promise.resolve(1))).rejects.toThrow( - /missing-shell|ENOENT|not found/ - ) - } finally { - failedLaunch.dispose() - } - } finally { - rmSync(directory, { recursive: true, force: true }) - } - }, 15_000) -}) diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-gate.test.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-gate.test.ts deleted file mode 100644 index a87870f80d5b..000000000000 --- a/src/main/daemon/pty-subprocess/windows-bun-pty-gate.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { EventEmitter } from 'node:events' -import { describe, expect, it, vi } from 'vitest' -import type { spawnProcess } from '../../../shared/child-process/run-process' -import { runWindowsBunPtyGate, type WindowsBunPtyGateRequest } from './windows-bun-pty-gate' - -const request: WindowsBunPtyGateRequest = { - file: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', - args: ['-NoLogo', '-NoExit', '-Command', 'A'.repeat(16000)], - cwd: 'C:\\work', - gatePath: 'gate', - shellPidPath: 'shell.pid', - runtimeOptions: {} -} - -describe('Windows Bun PTY job gate worker', () => { - it.each(['exit', 'error'] as const)( - 'ignores Windows console interrupts only while supervising a child (%s)', - async (outcome) => { - const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') - const previousListeners = process.listeners('SIGINT') - const child = new EventEmitter() - try { - const result = runWindowsBunPtyGate(request, { - waitForGate: async () => {}, - reportSpawnError: vi.fn(), - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture exposes only the child events the gate consumes. - spawn: () => child as ReturnType - }) - expect(process.listeners('SIGINT')).toHaveLength(previousListeners.length + 1) - await Promise.resolve() - if (outcome === 'exit') { - child.emit('exit', 17) - await expect(result).resolves.toBe(17) - } else { - child.emit('error', new Error('spawn denied')) - await expect(result).rejects.toThrow('spawn denied') - } - expect(process.listeners('SIGINT')).toEqual(previousListeners) - } finally { - platform.mockRestore() - } - } - ) - - it('does not spawn before assignment and propagates the child exit code', async () => { - let release!: () => void - const waitForGate = vi.fn( - () => - new Promise((resolve) => { - release = resolve - }) - ) - const child = Object.assign(new EventEmitter(), { pid: 1234 }) - const reportShellPid = vi.fn() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture exposes only the child events and pid the gate consumes. - const spawn = vi.fn(() => child as ReturnType) - const result = runWindowsBunPtyGate(request, { - waitForGate, - spawn, - reportShellPid, - env: { TERM: 'xterm-256color' } - }) - await Promise.resolve() - expect(spawn).not.toHaveBeenCalled() - release() - await Promise.resolve() - expect(spawn).toHaveBeenCalledWith( - expect.objectContaining({ - program: request.file, - args: request.args, - cwd: request.cwd, - stdio: 'inherit' - }) - ) - expect(reportShellPid).not.toHaveBeenCalled() - child.emit('spawn') - expect(reportShellPid).toHaveBeenCalledWith(1234) - child.emit('exit', 17) - await expect(result).resolves.toBe(17) - }) - - it('never starts a child after a failed job gate', async () => { - const spawn = vi.fn() - await expect( - runWindowsBunPtyGate(request, { - waitForGate: async () => { - throw new Error('gate missing') - }, - reportSpawnError: vi.fn(), - spawn - }) - ).rejects.toThrow('gate missing') - expect(spawn).not.toHaveBeenCalled() - }) - - it('keeps supervising the shell when its identity receipt cannot be published', async () => { - const child = Object.assign(new EventEmitter(), { pid: 1234 }) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const reportSpawnError = vi.fn() - const result = runWindowsBunPtyGate(request, { - waitForGate: async () => {}, - reportSpawnError, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture exposes only the child events and pid consumed by the gate. - spawn: () => child as ReturnType, - reportShellPid() { - throw new Error('receipt denied') - } - }) - await Promise.resolve() - child.emit('spawn') - expect(warn).toHaveBeenCalledOnce() - child.emit('exit', 17) - await expect(result).resolves.toBe(17) - expect(reportSpawnError).not.toHaveBeenCalled() - warn.mockRestore() - }) - - it('reports a child spawn error instead of a successful wrapper exit', async () => { - const child = new EventEmitter() - const reportSpawnError = vi.fn() - const result = runWindowsBunPtyGate(request, { - waitForGate: async () => {}, - reportSpawnError, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture exposes the error/exit events the gate consumes. - spawn: () => child as ReturnType - }) - await Promise.resolve() - child.emit('error', new Error('spawn denied')) - await expect(result).rejects.toThrow('spawn denied') - expect(reportSpawnError).toHaveBeenCalledWith( - expect.objectContaining({ message: 'spawn denied' }) - ) - }) - - it('reports synchronous native spawn rejection without requiring a child event', async () => { - const reportSpawnError = vi.fn() - await expect( - runWindowsBunPtyGate(request, { - waitForGate: async () => {}, - spawn: () => { - throw new Error('invalid executable') - }, - reportSpawnError - }) - ).rejects.toThrow('invalid executable') - expect(reportSpawnError).toHaveBeenCalledWith( - expect.objectContaining({ message: 'invalid executable' }) - ) - }) -}) diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-gate.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-gate.ts deleted file mode 100644 index 384038c98f76..000000000000 --- a/src/main/daemon/pty-subprocess/windows-bun-pty-gate.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { readFileSync, statSync, unlinkSync } from 'node:fs' -import { setTimeout as delay } from 'node:timers/promises' -import { win32 } from 'node:path' -import { spawnProcess, type ProcessSpec } from '../../../shared/child-process/run-process' -import { - publishWindowsBunPtyShellPid, - publishWindowsBunPtySpawnError -} from './windows-bun-pty-spawn-receipt' - -export const WINDOWS_BUN_PTY_GATE_ENV = 'ORCA_BUN_PTY_JOB_GATE' -export const WINDOWS_BUN_PTY_RUNTIME_OPTION_KEYS = ['NODE_OPTIONS', 'BUN_OPTIONS'] as const - -export type WindowsBunPtyGateRequest = { - file: string - args: string[] - cwd: string - gatePath: string - shellPidPath: string - runtimeOptions: Partial> -} - -export function readWindowsBunPtyGateRequest(path: string): WindowsBunPtyGateRequest { - if (statSync(path).size > 1024 * 1024) { - throw new Error('Windows PTY gate request exceeds its size limit') - } - let value: unknown - try { - value = JSON.parse(readFileSync(path, 'utf8')) - } catch { - throw new Error('Invalid Windows PTY gate request') - } - const request = value - if ( - typeof request !== 'object' || - request === null || - !('file' in request) || - typeof request.file !== 'string' || - !request.file || - !('args' in request) || - !Array.isArray(request.args) || - !request.args.every((arg): arg is string => typeof arg === 'string') || - !('cwd' in request) || - typeof request.cwd !== 'string' || - !request.cwd || - !('gatePath' in request) || - typeof request.gatePath !== 'string' || - !request.gatePath || - !('shellPidPath' in request) || - typeof request.shellPidPath !== 'string' || - !request.shellPidPath || - !('runtimeOptions' in request) || - typeof request.runtimeOptions !== 'object' || - request.runtimeOptions === null || - Array.isArray(request.runtimeOptions) - ) { - throw new Error('Invalid Windows PTY gate request') - } - const runtimeOptions: WindowsBunPtyGateRequest['runtimeOptions'] = {} - for (const [key, value] of Object.entries(request.runtimeOptions)) { - if ((key !== 'NODE_OPTIONS' && key !== 'BUN_OPTIONS') || typeof value !== 'string') { - throw new Error('Invalid Windows PTY gate request') - } - runtimeOptions[key] = value - } - return { - file: request.file, - args: request.args, - cwd: request.cwd, - gatePath: request.gatePath, - shellPidPath: request.shellPidPath, - runtimeOptions - } -} - -export async function waitForWindowsBunPtyJobGate(gatePath: string): Promise { - const deadline = Date.now() + 30_000 - while (true) { - try { - unlinkSync(gatePath) - return - } catch (error) { - if ( - typeof error !== 'object' || - error === null || - !('code' in error) || - error.code !== 'ENOENT' - ) { - throw error - } - } - if (Date.now() >= deadline) { - throw new Error('Windows PTY job assignment timed out') - } - await delay(5) - } -} - -export function windowsBunPtyChildSpec( - request: WindowsBunPtyGateRequest, - inheritedEnv: NodeJS.ProcessEnv -): ProcessSpec { - const env: NodeJS.ProcessEnv = { ...inheritedEnv, ...request.runtimeOptions } - delete env[WINDOWS_BUN_PTY_GATE_ENV] - delete env.ORCA_BUN_PTY_CHILD_COMMAND - return { - program: request.file, - args: request.args, - cwd: request.cwd, - env, - stdio: 'inherit', - // cmd owns the command text following /K or /C; it must not receive CRT argv escaping. - ...(win32.basename(request.file).toLowerCase() === 'cmd.exe' - ? { windowsVerbatimArguments: true } - : {}) - } -} - -export async function runWindowsBunPtyGate( - request: WindowsBunPtyGateRequest, - deps: { - waitForGate?: (gatePath: string) => Promise - spawn?: typeof spawnProcess - env?: NodeJS.ProcessEnv - reportShellPid?: (pid: number) => void - reportSpawnError?: (error: unknown) => void - } = {} -): Promise { - let spawned = false - // Preserve supervision when Ctrl-C reaches the entire Windows console. - const ignoreInterrupt = (): void => {} - if (process.platform === 'win32') { - process.on('SIGINT', ignoreInterrupt) - } - try { - await (deps.waitForGate ?? waitForWindowsBunPtyJobGate)(request.gatePath) - return await new Promise((resolve, reject) => { - const child = (deps.spawn ?? spawnProcess)( - windowsBunPtyChildSpec(request, deps.env ?? process.env) - ) - child.once('spawn', () => { - spawned = true - if (child.pid !== undefined) { - const report = - deps.reportShellPid ?? - ((pid) => publishWindowsBunPtyShellPid(request.shellPidPath, pid)) - try { - report(child.pid) - } catch (error) { - // Keep supervising the shell; absent identity must remain unverifiable. - console.warn('[pty] Failed to publish Windows shell identity:', error) - } - } - }) - child.once('error', reject) - child.once('exit', (code) => resolve(code ?? 1)) - }) - } catch (error) { - if (!spawned) { - try { - const report = - deps.reportSpawnError ?? - ((error) => publishWindowsBunPtySpawnError(request.shellPidPath, error)) - report(error) - } catch (receiptError) { - console.warn('[pty] Failed to publish Windows shell spawn error:', receiptError) - } - } - throw error - } finally { - process.off('SIGINT', ignoreInterrupt) - } -} diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-job.test.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-job.test.ts deleted file mode 100644 index ccc3ebc6d1ad..000000000000 --- a/src/main/daemon/pty-subprocess/windows-bun-pty-job.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - __resetWindowsBunPtyJobForTests, - assignCurrentProcessToBunPtyHostJob, - createWindowsBunPtyJob, - type WindowsBunPtyJobNative -} from './windows-bun-pty-job' - -function createNative(overrides: Partial = {}): WindowsBunPtyJobNative { - return { - createJob: vi.fn(() => 7), - configureJob: vi.fn(() => true), - currentProcess: vi.fn(() => 99), - openProcess: vi.fn((_access, pid) => 1_000 + pid), - assignProcess: vi.fn(() => true), - isProcessInJob: vi.fn(() => true), - queryProcessIds: vi.fn(() => [11]), - suspendProcess: vi.fn(() => true), - resumeProcess: vi.fn(() => true), - terminateJob: vi.fn(() => true), - closeHandle: vi.fn(), - ...overrides - } -} - -afterEach(() => { - vi.restoreAllMocks() - __resetWindowsBunPtyJobForTests() -}) - -describe('Windows Bun PTY job ownership', () => { - it('assigns the daemon to one kill-on-close host job', () => { - const native = createNative() - - expect(assignCurrentProcessToBunPtyHostJob(native)).toBe(true) - expect(assignCurrentProcessToBunPtyHostJob(native)).toBe(true) - - expect(native.createJob).toHaveBeenCalledOnce() - expect(native.configureJob).toHaveBeenCalledWith(7, 0x2800) - expect(native.assignProcess).toHaveBeenCalledWith(7, 99) - }) - - it('closes a rejected host job and caches the unavailable result', () => { - const native = createNative({ assignProcess: vi.fn(() => false) }) - - expect(assignCurrentProcessToBunPtyHostJob(native)).toBe(false) - expect(assignCurrentProcessToBunPtyHostJob(native)).toBe(false) - - expect(native.createJob).toHaveBeenCalledOnce() - expect(native.closeHandle).toHaveBeenCalledWith(7) - }) - - it('assigns the gated PTY root before exposing the job', () => { - const native = createNative() - - const job = createWindowsBunPtyJob(11, native) - - expect(job).not.toBeNull() - expect(native.configureJob).toHaveBeenCalledWith(7, 0) - expect(native.openProcess).toHaveBeenCalledWith(0x1901, 11) - expect(native.assignProcess).toHaveBeenCalledWith(7, 1011) - expect(native.closeHandle).toHaveBeenCalledWith(1011) - }) - - it('suspends children that appear during the ownership fence and resumes exact handles', () => { - const queryProcessIds = vi - .fn<() => readonly number[] | null>() - .mockReturnValueOnce([11, 12]) - .mockReturnValueOnce([11, 12, 13]) - .mockReturnValueOnce([11, 12, 13]) - .mockReturnValue([11, 12, 13]) - const native = createNative({ queryProcessIds }) - const job = createWindowsBunPtyJob(11, native)! - vi.mocked(native.closeHandle).mockClear() - - expect(job.pause()).toBe(true) - expect(native.suspendProcess).toHaveBeenCalledWith(1011) - expect(native.suspendProcess).toHaveBeenCalledWith(1012) - expect(native.suspendProcess).toHaveBeenCalledWith(1013) - expect(job.resume()).toBe(true) - - expect(native.resumeProcess).toHaveBeenCalledWith(1011) - expect(native.resumeProcess).toHaveBeenCalledWith(1012) - expect(native.resumeProcess).toHaveBeenCalledWith(1013) - expect(native.closeHandle).toHaveBeenCalledWith(1011) - expect(native.closeHandle).toHaveBeenCalledWith(1012) - expect(native.closeHandle).toHaveBeenCalledWith(1013) - }) - - it('never suspends a PID whose opened handle is outside the owned job', () => { - const native = createNative({ - queryProcessIds: vi.fn(() => [11, 12]), - isProcessInJob: vi.fn((process) => process !== 1012) - }) - const job = createWindowsBunPtyJob(11, native)! - - expect(job.pause()).toBe(false) - - expect(native.suspendProcess).toHaveBeenCalledWith(1011) - expect(native.suspendProcess).not.toHaveBeenCalledWith(1012) - expect(native.resumeProcess).toHaveBeenCalledWith(1011) - expect(native.closeHandle).toHaveBeenCalledWith(1012) - }) - - it('terminates a paused tree without resuming it first', () => { - const native = createNative({ queryProcessIds: vi.fn(() => [11]) }) - const job = createWindowsBunPtyJob(11, native)! - - expect(job.pause()).toBe(true) - expect(job.terminate()).toBe('terminated') - job.close() - - expect(native.terminateJob).toHaveBeenCalledWith(7) - expect(native.resumeProcess).not.toHaveBeenCalled() - expect(native.closeHandle).toHaveBeenCalledWith(1011) - expect(native.closeHandle).toHaveBeenCalledWith(7) - }) - - it('retains an exact handle when resume fails so a later retry can recover it', () => { - const resumeProcess = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true) - const native = createNative({ resumeProcess }) - const job = createWindowsBunPtyJob(11, native)! - vi.mocked(native.closeHandle).mockClear() - - expect(job.pause()).toBe(true) - expect(job.resume()).toBe(false) - expect(native.closeHandle).not.toHaveBeenCalledWith(1011) - expect(job.resume()).toBe(true) - expect(native.closeHandle).toHaveBeenCalledWith(1011) - }) - - it('keeps breakaway denied when forced termination needs kill-on-close', () => { - const native = createNative({ - resumeProcess: vi.fn(() => false), - terminateJob: vi.fn(() => false) - }) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const job = createWindowsBunPtyJob(11, native)! - expect(job.pause()).toBe(true) - job.close() - expect(native.configureJob).toHaveBeenLastCalledWith(7, 0x2000) - }) - - it('terminates a still-suspended tree instead of abandoning it during close', () => { - const native = createNative({ resumeProcess: vi.fn(() => false) }) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const job = createWindowsBunPtyJob(11, native)! - vi.mocked(native.closeHandle).mockClear() - - expect(job.pause()).toBe(true) - job.close() - - expect(native.terminateJob).toHaveBeenCalledWith(7) - expect(native.closeHandle).toHaveBeenCalledWith(1011) - expect(native.closeHandle).toHaveBeenCalledWith(7) - expect(warn).toHaveBeenCalledOnce() - }) -}) diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-job.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-job.ts deleted file mode 100644 index 5f0a7065c85e..000000000000 --- a/src/main/daemon/pty-subprocess/windows-bun-pty-job.ts +++ /dev/null @@ -1,218 +0,0 @@ -import type { JobTerminationOutcome } from '../../windows/windows-pty-job' -import { - __resetWindowsBunPtyNativeForTests, - loadWindowsBunPtyJobNative, - type WindowsBunPtyJobNative, - type WindowsNativeHandle -} from './windows-bun-pty-native' - -export type { WindowsBunPtyJobNative } from './windows-bun-pty-native' - -export type WindowsBunPtyJob = { - listProcessIds(): readonly number[] | null - pause(): boolean - resume(): boolean - terminate(): JobTerminationOutcome - close(): void -} - -const JOB_OBJECT_LIMIT_BREAKAWAY_OK = 0x0000_0800 -const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x0000_2000 -const PROCESS_TERMINATE = 0x0001 -const PROCESS_SET_QUOTA = 0x0100 -const PROCESS_SUSPEND_RESUME = 0x0800 -const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 -const MAX_SUSPEND_PASSES = 8 - -let hostJobAssigned: boolean | null = null - -export function assignCurrentProcessToBunPtyHostJob( - native: WindowsBunPtyJobNative | null = loadWindowsBunPtyJobNative() -): boolean { - if (hostJobAssigned !== null) { - return hostJobAssigned - } - if (!native) { - hostJobAssigned = false - return false - } - const job = native.createJob() - if ( - job === null || - !native.configureJob(job, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK) || - !native.assignProcess(job, native.currentProcess()) - ) { - if (job !== null) { - native.closeHandle(job) - } - hostJobAssigned = false - return false - } - // The host job deliberately lives until Windows closes it during process teardown. - hostJobAssigned = true - return true -} - -class BunPtyJob implements WindowsBunPtyJob { - private readonly suspended = new Map() - private closed = false - private fullySuspended = false - private terminated = false - - constructor( - private readonly rootPid: number, - private readonly handle: WindowsNativeHandle, - private readonly native: WindowsBunPtyJobNative - ) {} - - listProcessIds(): readonly number[] | null { - return this.closed ? null : this.native.queryProcessIds(this.handle) - } - - pause(): boolean { - if (this.closed || this.terminated) { - return false - } - if (this.fullySuspended) { - return true - } - if (this.suspended.size > 0 && !this.resume()) { - return false - } - for (let pass = 0; pass < MAX_SUSPEND_PASSES; pass += 1) { - const pids = this.listProcessIds() - if (!pids) { - this.resume() - return false - } - const ordered = [...pids].sort((left, right) => { - if (left === this.rootPid) { - return -1 - } - if (right === this.rootPid) { - return 1 - } - return left - right - }) - let progressed = false - for (const pid of ordered) { - if (this.suspended.has(pid)) { - continue - } - const process = this.native.openProcess( - PROCESS_SUSPEND_RESUME | PROCESS_QUERY_LIMITED_INFORMATION, - pid - ) - if (process === null) { - continue - } - if (!this.native.isProcessInJob(process, this.handle)) { - this.native.closeHandle(process) - continue - } - if (!this.native.suspendProcess(process)) { - this.native.closeHandle(process) - continue - } - this.suspended.set(pid, process) - progressed = true - } - const remaining = this.listProcessIds() - if (remaining && remaining.every((pid) => this.suspended.has(pid))) { - this.fullySuspended = true - return true - } - if (!remaining || !progressed) { - this.resume() - return false - } - } - this.resume() - return false - } - - resume(): boolean { - this.fullySuspended = false - const ownedPids = this.terminated ? [] : this.listProcessIds() - for (const [pid, process] of this.suspended) { - const processExited = ownedPids !== null && !ownedPids.includes(pid) - if (!this.terminated && !processExited && !this.native.resumeProcess(process)) { - continue - } - this.native.closeHandle(process) - this.suspended.delete(pid) - } - return this.suspended.size === 0 - } - - terminate(): JobTerminationOutcome { - if (this.closed) { - return this.terminated ? 'terminated' : 'unavailable' - } - if (!this.terminated) { - this.terminated = this.native.terminateJob(this.handle) - } - if (this.terminated) { - this.resume() - return 'terminated' - } - return 'unavailable' - } - - close(): void { - if (this.closed) { - return - } - if (!this.resume()) { - console.warn( - '[daemon/pty] Could not resume a Windows PTY tree during cleanup; terminating it' - ) - this.terminated = this.native.terminateJob(this.handle) - if (!this.terminated) { - this.terminated = this.native.configureJob(this.handle, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE) - } - this.resume() - } - this.native.closeHandle(this.handle) - this.closed = true - } -} - -export function createWindowsBunPtyJob( - rootPid: number, - native: WindowsBunPtyJobNative | null = loadWindowsBunPtyJobNative() -): WindowsBunPtyJob | null { - if (!native || !Number.isInteger(rootPid) || rootPid <= 0) { - return null - } - const job = native.createJob() - if (job === null || !native.configureJob(job, 0)) { - if (job !== null) { - native.closeHandle(job) - } - return null - } - const process = native.openProcess( - PROCESS_SET_QUOTA | - PROCESS_TERMINATE | - PROCESS_SUSPEND_RESUME | - PROCESS_QUERY_LIMITED_INFORMATION, - rootPid - ) - if (process === null) { - native.closeHandle(job) - return null - } - const assigned = native.assignProcess(job, process) - native.closeHandle(process) - if (!assigned) { - native.closeHandle(job) - return null - } - return new BunPtyJob(rootPid, job, native) -} - -export function __resetWindowsBunPtyJobForTests(): void { - __resetWindowsBunPtyNativeForTests() - hostJobAssigned = null -} diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-launch.test.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-launch.test.ts deleted file mode 100644 index 1f4df4ef749f..000000000000 --- a/src/main/daemon/pty-subprocess/windows-bun-pty-launch.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { existsSync, readFileSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { createWindowsBunPtyLaunch, resolveWindowsBunPtyGateEntry } from './windows-bun-pty-launch' -import { readWindowsBunPtyGateRequest, windowsBunPtyChildSpec } from './windows-bun-pty-gate' -import { publishWindowsBunPtyShellPid } from './windows-bun-pty-spawn-receipt' - -const workerPath = join(__dirname, 'windows-bun-pty-launch.test.ts') - -describe('Windows Bun PTY gated launch', () => { - it('reads only the atomic shell receipt and retains its first identity through cleanup', () => { - const launch = createWindowsBunPtyLaunch( - { file: 'shell.exe', args: [], env: {} }, - { workerPath } - ) - const { shellPidPath } = readWindowsBunPtyGateRequest(launch.command.at(-1)!) - try { - expect(launch.readShellProcessId()).toBeUndefined() - writeFileSync(`${shellPidPath}.pending`, '12') - expect(launch.readShellProcessId()).toBeUndefined() - writeFileSync(shellPidPath, 'not a PID') - expect(launch.readShellProcessId()).toBeUndefined() - for (const receipt of ['0', '-123', '4294967296', '123\n456', '1e3', '12.3']) { - writeFileSync(shellPidPath, receipt) - expect(launch.readShellProcessId()).toBeUndefined() - } - writeFileSync(shellPidPath, '1234') - expect(launch.readShellProcessId()).toBe(1234) - writeFileSync(shellPidPath, '5678') - expect(launch.readShellProcessId()).toBe(1234) - } finally { - launch.dispose() - } - expect(launch.readShellProcessId()).toBe(1234) - }) - - it('publishes a complete PID and leaves no intermediate receipt', () => { - const launch = createWindowsBunPtyLaunch( - { file: 'shell.exe', args: [], env: {} }, - { workerPath } - ) - const { shellPidPath } = readWindowsBunPtyGateRequest(launch.command.at(-1)!) - try { - publishWindowsBunPtyShellPid(shellPidPath, 1234) - expect(launch.readShellProcessId()).toBe(1234) - expect(existsSync(`${shellPidPath}.pending`)).toBe(false) - } finally { - launch.dispose() - } - }) - - it('preserves long executable argv without cmd interpretation and releases only once', () => { - const file = 'C:\\状 態\\%tool%&shell.exe' - const args = ['a b', 'c"d', 'e%F%g', 'h&i', 'j^k', 'bang!', 'line\nbreak', 'x'.repeat(16000)] - const launch = createWindowsBunPtyLaunch( - { file, args, cwd: 'C:\\work tree', env: { TERM: 'xterm-256color' } }, - { workerPath } - ) - const gate = launch.env.ORCA_BUN_PTY_JOB_GATE - const directory = dirname(gate) - try { - const request = readWindowsBunPtyGateRequest(launch.command.at(-1)!) - expect(request).toMatchObject({ file, args, cwd: 'C:\\work tree', gatePath: gate }) - const child = windowsBunPtyChildSpec(request, launch.env) - expect(child.program).toBe(file) - expect(child.args).toEqual(args) - expect(child.windowsVerbatimArguments).toBeUndefined() - expect(child.stdio).toBe('inherit') - expect(child.env).not.toHaveProperty('ORCA_BUN_PTY_JOB_GATE') - expect(launch.windowsVerbatimArguments).toBe(false) - expect(launch.command).toContain('--no-env-file') - expect(launch.command).toContain(`--config=${join(directory, 'bunfig.toml')}`) - expect(launch.command).toContain(`--cwd=${directory}`) - expect(launch.command.join(' ').length).toBeLessThan(8191) - const clear = readFileSync(join(directory, 'clear.cmd')) - expect(clear.includes(Buffer.from('\x1b[3J\x1b[2J\x1b[H'))).toBe(true) - expect(existsSync(gate)).toBe(false) - launch.release() - launch.release() - expect(existsSync(gate)).toBe(true) - } finally { - launch.dispose() - launch.dispose() - } - expect(existsSync(directory)).toBe(false) - }) - - it.each(['/K', '/k', '/C', '/c'])( - 'preserves direct cmd %s command text without CRT escaping', - (commandSwitch) => { - const file = 'C:\\Windows\\System32\\CMD.EXE' - const args = [commandSwitch, 'chcp 65001 > nul & echo 状態%VALUE%!'] - const launch = createWindowsBunPtyLaunch({ file, args, env: {} }, { workerPath }) - try { - const child = windowsBunPtyChildSpec( - readWindowsBunPtyGateRequest(launch.command.at(-1)!), - launch.env - ) - expect(child.program).toBe(file) - expect(child.args).toEqual(args) - expect(child.windowsVerbatimArguments).toBe(true) - } finally { - launch.dispose() - } - } - ) - - it('withholds runtime preload options from the gate while preserving the shell environment', () => { - const env = { - NODE_OPTIONS: '--require C:\\workspace\\hook.js', - BUN_OPTIONS: '--preload hook.js', - TERM: 'xterm-256color' - } - const launch = createWindowsBunPtyLaunch({ file: 'shell.exe', args: [], env }, { workerPath }) - try { - expect(launch.env).not.toHaveProperty('NODE_OPTIONS') - expect(launch.env).not.toHaveProperty('BUN_OPTIONS') - expect( - windowsBunPtyChildSpec(readWindowsBunPtyGateRequest(launch.command.at(-1)!), launch.env).env - ).toEqual(env) - } finally { - launch.dispose() - } - }) - - it('fails before launch when the gate entry is missing', () => { - expect(() => - createWindowsBunPtyLaunch( - { file: 'shell.exe', args: [], env: {} }, - { workerPath: join(workerPath, 'missing') } - ) - ).toThrow('Windows PTY gate entry not found') - }) - - it('rejects a cmd-unsafe line break before creating launch state', () => { - expect(() => - createWindowsBunPtyLaunch( - { file: 'C:\\Windows\\System32\\cmd.exe', args: ['/c', 'first\nsecond'], env: {} }, - { workerPath } - ) - ).toThrow('cmd.exe cannot receive an argument containing a line break') - }) - - it('resolves adjacent, factored-chunk, and unpacked desktop layouts', () => { - const name = 'windows-bun-pty-gate-entry.js' - expect(resolveWindowsBunPtyGateEntry('/orcad', () => true)).toBe(join('/orcad', name)) - expect( - resolveWindowsBunPtyGateEntry( - '/app/out/main/chunks', - (path) => path === join('/app/out/main', name) - ) - ).toBe(join('/app/out/main', name)) - expect(resolveWindowsBunPtyGateEntry('/resources/app.asar/out/main', () => true)).toBe( - join('/resources/app.asar.unpacked/out/main', name) - ) - }) -}) diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-launch.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-launch.ts deleted file mode 100644 index dda83ad021a2..000000000000 --- a/src/main/daemon/pty-subprocess/windows-bun-pty-launch.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join, win32 } from 'node:path' -import { - buildWindowsCmdShimCommandLine, - validateWindowsCmdArguments -} from '../../../shared/child-process/windows-command-line' -import { getCmdExePath } from '../../../shared/windows-batch-spawn' -import { - WINDOWS_BUN_PTY_GATE_ENV, - WINDOWS_BUN_PTY_RUNTIME_OPTION_KEYS, - type WindowsBunPtyGateRequest -} from './windows-bun-pty-gate' -import { - readWindowsBunPtySpawnReceipt, - waitForWindowsBunPtySpawn, - type WindowsBunPtySpawnReceipt -} from './windows-bun-pty-spawn-receipt' - -const CLEAR_SEQUENCE = '\x1b[3J\x1b[2J\x1b[H' -const CLEANUP_MAX_RETRIES = 5 -const CLEANUP_RETRY_DELAY_MS = 50 - -export function resolveWindowsBunPtyGateEntry( - runtimeDir = __dirname, - pathExists: (path: string) => boolean = existsSync -): string { - const directory = runtimeDir.replace(/app\.asar(?=[\\/]|$)/, 'app.asar.unpacked') - const candidates = [ - join(directory, 'windows-bun-pty-gate-entry.js'), - join(directory, '..', 'windows-bun-pty-gate-entry.js') - ] - return candidates.find(pathExists) ?? candidates[0]! -} - -function removeLaunchDirectory(directory: string): boolean { - try { - rmSync(directory, { - recursive: true, - force: true, - maxRetries: CLEANUP_MAX_RETRIES, - retryDelay: CLEANUP_RETRY_DELAY_MS - }) - return true - } catch (error) { - console.warn(`[pty] failed to remove Windows Bun launch directory ${directory}:`, error) - return false - } -} - -export type WindowsBunPtyLaunch = { - command: string[] - clearCommand: string[] - env: Record - windowsVerbatimArguments: boolean - readShellProcessId(): number | undefined - waitForSpawn(wrapperExited: Promise): Promise - release(): void - dispose(): void -} - -export function createWindowsBunPtyLaunch( - args: { - file: string - args: string[] - env: Record - cwd?: string - }, - deps: { workerPath?: string; runtimePath?: string } = {} -): WindowsBunPtyLaunch { - if (win32.basename(args.file).toLowerCase() === 'cmd.exe') { - validateWindowsCmdArguments([args.file, ...args.args]) - } - const workerPath = deps.workerPath ?? resolveWindowsBunPtyGateEntry() - if (!existsSync(workerPath)) { - throw new Error(`Windows PTY gate entry not found: ${workerPath}`) - } - const directory = mkdtempSync(join(tmpdir(), 'orca-bun-pty-')) - const gatePath = join(directory, 'job-assigned') - const requestPath = join(directory, 'request.json') - const shellPidPath = join(directory, 'shell.pid') - const configPath = join(directory, 'bunfig.toml') - const clearPath = join(directory, 'clear.cmd') - const cmdExe = getCmdExePath() - let released = false - let disposed = false - let spawnReceipt: WindowsBunPtySpawnReceipt | undefined - const readSpawnReceipt = (): WindowsBunPtySpawnReceipt | undefined => { - if (!disposed) { - spawnReceipt ??= readWindowsBunPtySpawnReceipt(shellPidPath) - } - return spawnReceipt - } - const env: Record = { ...args.env, [WINDOWS_BUN_PTY_GATE_ENV]: gatePath } - const runtimeOptions: WindowsBunPtyGateRequest['runtimeOptions'] = {} - for (const key of WINDOWS_BUN_PTY_RUNTIME_OPTION_KEYS) { - if (env[key] !== undefined) { - runtimeOptions[key] = env[key] - } - delete env[key] - } - - try { - writeFileSync( - requestPath, - JSON.stringify({ - file: args.file, - args: args.args, - cwd: args.cwd ?? process.cwd(), - gatePath, - shellPidPath, - runtimeOptions - } satisfies WindowsBunPtyGateRequest), - { encoding: 'utf8', flag: 'wx', mode: 0o600 } - ) - writeFileSync(configPath, '', { flag: 'wx', mode: 0o600 }) - writeFileSync(clearPath, `@echo off\r\n waitForWindowsBunPtySpawn(readSpawnReceipt, wrapperExited), - release() { - if (released) { - return - } - writeFileSync(gatePath, '', { flag: 'wx' }) - released = true - }, - dispose() { - if (disposed) { - return - } - readSpawnReceipt() - disposed = removeLaunchDirectory(directory) - } - } -} diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-native.test.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-native.test.ts deleted file mode 100644 index de6ff4ee264d..000000000000 --- a/src/main/daemon/pty-subprocess/windows-bun-pty-native.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { queryWindowsBunPtyProcessIds } from './windows-bun-pty-native' - -function writeProcessList(bytes: Uint8Array, pids: number[]): boolean { - const capacity = (bytes.byteLength - 8) / 8 - const view = new DataView(bytes.buffer) - view.setUint32(0, pids.length, true) - view.setUint32(4, Math.min(pids.length, capacity), true) - pids - .slice(0, capacity) - .forEach((pid, index) => view.setBigUint64(8 + index * 8, BigInt(pid), true)) - return pids.length <= capacity -} - -describe('Windows Bun job process enumeration', () => { - it.each([false, true])( - 'grows an incomplete process list when the native call returns %s', - (result) => { - const pids = Array.from({ length: 257 }, (_, index) => index + 1) - const query = vi.fn((bytes: Uint8Array) => writeProcessList(bytes, pids) || result) - expect(queryWindowsBunPtyProcessIds(query)).toEqual(pids) - expect(query.mock.calls.map(([bytes]) => (bytes.byteLength - 8) / 8)).toEqual([64, 256, 1024]) - } - ) - - it('does not mistake a failed native query for an empty job', () => { - const query = vi.fn(() => false) - expect(queryWindowsBunPtyProcessIds(query)).toBeNull() - expect(query).toHaveBeenCalledOnce() - }) - - it('returns an empty list only when the native query succeeds', () => { - expect(queryWindowsBunPtyProcessIds(() => true)).toEqual([]) - }) - - it('bounds growth when a process tree exceeds the inventory limit', () => { - const query = vi.fn((bytes: Uint8Array) => { - new DataView(bytes.buffer).setUint32(0, 20_000, true) - return false - }) - expect(queryWindowsBunPtyProcessIds(query)).toBeNull() - expect(query).toHaveBeenCalledTimes(5) - }) - - it.each([0, 0x1_0000_0000])( - 'refuses invalid PID %s without reporting partial ownership', - (pid) => { - expect( - queryWindowsBunPtyProcessIds((bytes) => writeProcessList(bytes, [1234, pid])) - ).toBeNull() - } - ) -}) diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-native.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-native.ts deleted file mode 100644 index c9d72e9bf9d1..000000000000 --- a/src/main/daemon/pty-subprocess/windows-bun-pty-native.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { createRequire } from 'node:module' - -export type WindowsNativeHandle = number | bigint -type NativePointer = number | bigint - -export type WindowsBunPtyJobNative = { - createJob(): WindowsNativeHandle | null - configureJob(job: WindowsNativeHandle, flags: number): boolean - currentProcess(): WindowsNativeHandle - openProcess(access: number, pid: number): WindowsNativeHandle | null - assignProcess(job: WindowsNativeHandle, process: WindowsNativeHandle): boolean - isProcessInJob(process: WindowsNativeHandle, job: WindowsNativeHandle): boolean - queryProcessIds(job: WindowsNativeHandle): readonly number[] | null - suspendProcess(process: WindowsNativeHandle): boolean - resumeProcess(process: WindowsNativeHandle): boolean - terminateJob(job: WindowsNativeHandle): boolean - closeHandle(handle: WindowsNativeHandle): void -} - -type FfiFunction = { args: readonly string[]; returns: string } -type FfiLibrary = { symbols: T } -type BunFfi = { - dlopen(name: string, symbols: Record): FfiLibrary - ptr(view: ArrayBufferView): NativePointer -} - -type Kernel32 = { - CreateJobObjectW(attributes: null, name: null): WindowsNativeHandle | null - SetInformationJobObject( - job: WindowsNativeHandle, - infoClass: number, - info: NativePointer, - infoLength: number - ): number - GetCurrentProcess(): WindowsNativeHandle - OpenProcess(access: number, inherit: number, pid: number): WindowsNativeHandle | null - AssignProcessToJobObject(job: WindowsNativeHandle, process: WindowsNativeHandle): number - IsProcessInJob( - process: WindowsNativeHandle, - job: WindowsNativeHandle, - result: NativePointer - ): number - QueryInformationJobObject( - job: WindowsNativeHandle, - infoClass: number, - info: NativePointer, - infoLength: number, - returnLength: null - ): number - TerminateJobObject(job: WindowsNativeHandle, exitCode: number): number - CloseHandle(handle: WindowsNativeHandle): number -} - -type Ntdll = { - NtSuspendProcess(process: WindowsNativeHandle): number - NtResumeProcess(process: WindowsNativeHandle): number -} - -const requireFromMain = createRequire(__filename) -const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9 -const JOB_OBJECT_BASIC_PROCESS_ID_LIST = 3 -const JOB_LIMIT_FLAGS_OFFSET = 16 -const JOB_EXTENDED_LIMITS_BYTES = 144 -const MAX_JOB_PROCESS_IDS = 16_384 - -export function queryWindowsBunPtyProcessIds( - query: (buffer: Uint8Array) => boolean -): readonly number[] | null { - for (let capacity = 64; capacity <= MAX_JOB_PROCESS_IDS; capacity *= 4) { - const bytes = new Uint8Array(8 + capacity * 8) - const queried = query(bytes) - const view = new DataView(bytes.buffer) - const assigned = view.getUint32(0, true) - const count = view.getUint32(4, true) - // These output counts survive the FFI boundary; thread-local GetLastError may not. - if (assigned > count) { - continue - } - if (!queried || count > capacity) { - return null - } - const pids: number[] = [] - for (let index = 0; index < count; index += 1) { - const pid = Number(view.getBigUint64(8 + index * 8, true)) - if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 0xffff_ffff) { - return null - } - pids.push(pid) - } - return pids - } - return null -} - -let cachedNative: WindowsBunPtyJobNative | null | undefined - -export function loadWindowsBunPtyJobNative(): WindowsBunPtyJobNative | null { - if (cachedNative !== undefined) { - return cachedNative - } - if (process.platform !== 'win32') { - cachedNative = null - return cachedNative - } - try { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned Bun runtime supplies these FFI exports; loading failures refuse job ownership. - const ffi = requireFromMain('bun:ffi') as BunFfi - const kernel = ffi.dlopen('kernel32.dll', { - CreateJobObjectW: { args: ['ptr', 'ptr'], returns: 'ptr' }, - SetInformationJobObject: { args: ['ptr', 'u32', 'ptr', 'u32'], returns: 'i32' }, - GetCurrentProcess: { args: [], returns: 'ptr' }, - OpenProcess: { args: ['u32', 'i32', 'u32'], returns: 'ptr' }, - AssignProcessToJobObject: { args: ['ptr', 'ptr'], returns: 'i32' }, - IsProcessInJob: { args: ['ptr', 'ptr', 'ptr'], returns: 'i32' }, - QueryInformationJobObject: { - args: ['ptr', 'u32', 'ptr', 'u32', 'ptr'], - returns: 'i32' - }, - TerminateJobObject: { args: ['ptr', 'u32'], returns: 'i32' }, - CloseHandle: { args: ['ptr'], returns: 'i32' } - }) - const ntdll = ffi.dlopen('ntdll.dll', { - NtSuspendProcess: { args: ['ptr'], returns: 'i32' }, - NtResumeProcess: { args: ['ptr'], returns: 'i32' } - }) - const { symbols } = kernel - cachedNative = { - createJob: () => symbols.CreateJobObjectW(null, null), - configureJob(job, flags) { - const limits = new Uint8Array(JOB_EXTENDED_LIMITS_BYTES) - new DataView(limits.buffer).setUint32(JOB_LIMIT_FLAGS_OFFSET, flags, true) - return ( - symbols.SetInformationJobObject( - job, - JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, - ffi.ptr(limits), - limits.byteLength - ) !== 0 - ) - }, - currentProcess: () => symbols.GetCurrentProcess(), - openProcess: (access, pid) => symbols.OpenProcess(access, 0, pid), - assignProcess: (job, process) => symbols.AssignProcessToJobObject(job, process) !== 0, - isProcessInJob(process, job) { - const result = new Uint32Array(1) - return symbols.IsProcessInJob(process, job, ffi.ptr(result)) !== 0 && result[0] !== 0 - }, - queryProcessIds(job) { - return queryWindowsBunPtyProcessIds( - (bytes) => - symbols.QueryInformationJobObject( - job, - JOB_OBJECT_BASIC_PROCESS_ID_LIST, - ffi.ptr(bytes), - bytes.byteLength, - null - ) !== 0 - ) - }, - suspendProcess: (process) => ntdll.symbols.NtSuspendProcess(process) >= 0, - resumeProcess: (process) => ntdll.symbols.NtResumeProcess(process) >= 0, - terminateJob: (job) => symbols.TerminateJobObject(job, 1) !== 0, - closeHandle: (handle) => { - symbols.CloseHandle(handle) - } - } - return cachedNative - } catch { - cachedNative = null - return cachedNative - } -} - -export function __resetWindowsBunPtyNativeForTests(): void { - cachedNative = undefined -} diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-spawn-receipt.test.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-spawn-receipt.test.ts deleted file mode 100644 index 568245219212..000000000000 --- a/src/main/daemon/pty-subprocess/windows-bun-pty-spawn-receipt.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { existsSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { createWindowsBunPtyLaunch } from './windows-bun-pty-launch' -import { readWindowsBunPtyGateRequest } from './windows-bun-pty-gate' -import { - publishWindowsBunPtyShellPid, - publishWindowsBunPtySpawnError, - WindowsBunPtySpawnUnconfirmedError -} from './windows-bun-pty-spawn-receipt' - -const workerPath = join(__dirname, 'windows-bun-pty-spawn-receipt.test.ts') -const neverExits = new Promise(() => {}) - -describe('Windows Bun shell spawn confirmation', () => { - afterEach(() => vi.useRealTimers()) - - it('waits for the actual shell and preserves successful immediate exit through cleanup', async () => { - const launch = createWindowsBunPtyLaunch( - { file: 'shell.exe', args: [], env: {} }, - { workerPath } - ) - const { shellPidPath } = readWindowsBunPtyGateRequest(launch.command.at(-1)!) - const ready = vi.fn() - let exit!: (code: number) => void - const exited = new Promise((resolve) => { - exit = resolve - }) - const waiting = launch.waitForSpawn(exited).then(ready) - try { - await Promise.resolve() - expect(ready).not.toHaveBeenCalled() - publishWindowsBunPtyShellPid(shellPidPath, 1234) - exit(17) - launch.dispose() - await waiting - expect(ready).toHaveBeenCalledOnce() - expect(launch.readShellProcessId()).toBe(1234) - expect(existsSync(dirname(shellPidPath))).toBe(false) - } finally { - launch.dispose() - } - }) - - it('preserves a definite spawn error through cleanup so the caller can retry another shell', async () => { - const launch = createWindowsBunPtyLaunch( - { file: 'shell.exe', args: [], env: {} }, - { workerPath } - ) - const { shellPidPath } = readWindowsBunPtyGateRequest(launch.command.at(-1)!) - try { - publishWindowsBunPtySpawnError(shellPidPath, new Error('spawn ENOENT')) - expect(existsSync(`${shellPidPath}.error.pending`)).toBe(false) - launch.dispose() - await expect(launch.waitForSpawn(Promise.resolve(1))).rejects.toThrow('spawn ENOENT') - } finally { - launch.dispose() - } - }) - - it('refuses to retry an exited gate without a receipt because its shell may have run', async () => { - const launch = createWindowsBunPtyLaunch( - { file: 'shell.exe', args: [], env: {} }, - { workerPath } - ) - try { - await expect(launch.waitForSpawn(Promise.resolve(0))).rejects.toBeInstanceOf( - WindowsBunPtySpawnUnconfirmedError - ) - } finally { - launch.dispose() - } - }) - - it('bounds the wait for a live gate that never publishes its shell identity', async () => { - vi.useFakeTimers({ toFake: ['Date'] }) - const launch = createWindowsBunPtyLaunch( - { file: 'shell.exe', args: [], env: {} }, - { workerPath } - ) - try { - const waiting = launch.waitForSpawn(neverExits) - const assertion = expect(waiting).rejects.toBeInstanceOf(WindowsBunPtySpawnUnconfirmedError) - vi.setSystemTime(Date.now() + 30_001) - await assertion - } finally { - launch.dispose() - } - }) -}) diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-spawn-receipt.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-spawn-receipt.ts deleted file mode 100644 index 43d193b76af0..000000000000 --- a/src/main/daemon/pty-subprocess/windows-bun-pty-spawn-receipt.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { readFileSync, renameSync, writeFileSync } from 'node:fs' -import { setTimeout as delay } from 'node:timers/promises' - -export type WindowsBunPtySpawnReceipt = { pid: number } | { error: string } - -export class WindowsBunPtySpawnUnconfirmedError extends Error {} - -function publishReceipt(path: string, value: string): void { - const pending = `${path}.pending` - writeFileSync(pending, value, { flag: 'wx', mode: 0o600 }) - // ConPTY cannot inherit Bun IPC; publish the receipt atomically. - renameSync(pending, path) -} - -export function publishWindowsBunPtyShellPid(path: string, pid: number): void { - publishReceipt(path, String(pid)) -} - -export function publishWindowsBunPtySpawnError(path: string, error: unknown): void { - publishReceipt(`${path}.error`, error instanceof Error ? error.message : String(error)) -} - -export function readWindowsBunPtySpawnReceipt(path: string): WindowsBunPtySpawnReceipt | undefined { - try { - const receipt = readFileSync(path, 'utf8') - const pid = Number(receipt) - if (/^[1-9][0-9]{0,9}$/.test(receipt) && Number.isSafeInteger(pid) && pid <= 0xffff_ffff) { - return { pid } - } - } catch { - // Missing or unreadable identity never proves the shell failed to spawn. - } - try { - return { error: readFileSync(`${path}.error`, 'utf8') } - } catch { - return undefined - } -} - -export async function waitForWindowsBunPtySpawn( - readReceipt: () => WindowsBunPtySpawnReceipt | undefined, - wrapperExited: Promise -): Promise { - let ended = false - const markEnded = (): void => { - ended = true - } - void wrapperExited.then(markEnded, markEnded) - const deadline = Date.now() + 30_000 - while (true) { - const receipt = readReceipt() - if (receipt) { - if ('pid' in receipt) { - return - } - if (ended) { - throw new Error(receipt.error) - } - } - if (ended || Date.now() >= deadline) { - // An unreported shell may already have run a startup command; never retry it. - throw new WindowsBunPtySpawnUnconfirmedError('Windows shell spawn could not be confirmed') - } - await delay(5) - } -} diff --git a/src/main/daemon/serialize-grid-transcript-replay.test.ts b/src/main/daemon/serialize-grid-transcript-replay.test.ts index e404ccb04e2c..28f0eed4459e 100644 --- a/src/main/daemon/serialize-grid-transcript-replay.test.ts +++ b/src/main/daemon/serialize-grid-transcript-replay.test.ts @@ -68,7 +68,38 @@ const KNOWN_PREEXISTING_I2_FAILURES: Record = { // (`build-serialize-addon-at-ref.mjs --ref origin/main`): I1 and I3 both hold. 'dsh-tui-ready-no-key': 10, // Hermes banner cells restore with an extra bold bit under the jitter schedule. - 'hermes-tui-ready': 2 + 'hermes-tui-ready': 2, + // STA-8741 agy/Cline/Prime captures, serializer untouched: the same true-colour background + // left on restored cells as DSH, plus Prime's cursor row after its alternate-screen repaints. + 'antigravity-1-2-14-busy-thinking': 2, + 'antigravity-1-2-14-command-palette': 4, + 'antigravity-1-2-14-draft': 4, + 'antigravity-1-2-14-model-picker': 2, + 'antigravity-1-2-14-picker-dismissed': 2, + 'antigravity-1-2-14-ready': 12, + 'antigravity-1-2-14-ready-80x24': 2, + 'antigravity-1-2-14-ready-accept-edits': 4, + 'antigravity-1-2-14-ready-plan': 4, + 'antigravity-1-2-14-trust-dialog': 26, + 'cline-3-0-66-busy-streaming': 6, + 'cline-3-0-66-draft': 6, + 'cline-3-0-66-permission': 6, + 'cline-3-0-66-promo': 15, + 'cline-3-0-66-ready': 11, + 'cline-3-0-66-ready-80x24': 2, + 'cline-3-0-66-slash-menu': 6, + 'cline-3-0-66-turn-ended': 10, + 'prime-agent-0-9-5-ready': 18, + 'prime-agent-0-9-5-turn': 4, + 'prime-agent-0-9-8-busy-streaming': 16, + 'prime-agent-0-9-8-draft': 10, + 'prime-agent-0-9-8-ready': 22, + 'prime-agent-0-9-8-ready-80x24': 16, + 'prime-agent-0-9-8-ready-after-question': 2, + 'prime-agent-0-9-8-slash-menu': 34, + 'prime-agent-0-9-8-tool-turn': 40, + 'prime-agent-0-9-8-trace-question': 4, + 'prime-agent-0-9-8-turn-ended': 24 } // Exact resize checkpoints and full GridDiff hashes from base 6835b9b4e3ea, not this branch. diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index 65e6815b47d0..c9c69d99123d 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Session } from './session' import { SESSION_FORCE_KILL_RETRY_MS } from './session-termination-controller' -import { HeadlessEmulator } from './headless-emulator' import type { SessionState, ShellReadyState } from './types' import type { TuiAgent } from '../../shared/tui-agent' import { @@ -491,29 +490,6 @@ describe('Session', () => { expect(session.getSnapshot()?.snapshotAnsi).not.toContain('orca-shell-ready') }) - it.each([ - ['after the ready marker', ['\x1b]777;orca-shell-ready\x07', '\x1b[?2004hfish> ']], - ['after the ESC introducer', ['\x1b]777;orca-shell-ready\x07\x1b', '[?2004hfish> ']] - ])('preserves Fish bracketed-paste output split %s', (_boundary, chunks) => { - createSession({ shellReadySupported: true }) - const received: string[] = [] - session.attachClient({ - onData: (data) => received.push(data), - onExit: () => {} - }) - - for (const chunk of chunks) { - subprocess.simulateData(chunk) - } - - const output = received.join('') - expect(output).toBe('\x1b[?2004hfish> ') - const rendered = new HeadlessEmulator({ cols: 80, rows: 24 }) - expect(rendered.writeSync(output)).toBe(true) - expect(rendered.getVisibleLines().join('\n')).not.toContain('[?2004h') - rendered.dispose() - }) - it('publishes an absolute output sequence with live snapshots', () => { createSession() subprocess.simulateData('first') diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts index 01cdad10be92..072ebe450569 100644 --- a/src/main/daemon/shell-ready.test.ts +++ b/src/main/daemon/shell-ready.test.ts @@ -444,23 +444,6 @@ describePosix('daemon shell-ready launch config', () => { } }) - it('owns zle-line-init for the shell-ready marker instead of an azhw hook', async () => { - const { getShellReadyLaunchConfig } = await importFreshShellReady() - - getShellReadyLaunchConfig('/bin/zsh') - - // Why .zshenv: the widget registration lives in the deferred hook, which the - // first prompt's precmd sweep calls exactly once. - const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - expect(zshenv).toContain('zle -N zle-line-init __orca_prompt_mark') - expect(zshenv).toContain('__orca_prev_line_init_fn="${widgets[zle-line-init]#user:}"') - expect(zshenv).toContain('printf "\\033]777;orca-shell-ready\\007"') - // Why: add-zle-hook-widget aborts its chain when an earlier hook exits non-zero, so don't register the marker through it. - expect(zshenv).not.toContain('add-zle-hook-widget line-init') - // Why: re-source guard — skip re-capturing when already the bound widget so the prior chain survives a second source. - expect(zshenv).toContain('== "user:__orca_prompt_mark"') - }) - // Why: oh-my-zsh vi-mode's zle-line-init returns non-zero; add-zle-hook-widget then aborts the chain and the marker never fires. itWithZsh( 'emits the shell-ready marker even when a user zle-line-init widget fails (oh-my-zsh vi-mode shape)', @@ -699,21 +682,4 @@ describePosix('daemon shell-ready launch config', () => { } } }) - - it('sources the user .zshenv at wrapper top level, not inside a function', async () => { - // Why: PR #1737 sourced .zshenv in a wrapper function, breaking `typeset -U - // path`. Top-level sourcing is still the contract. - const { getShellReadyLaunchConfig } = await importFreshShellReady() - - getShellReadyLaunchConfig('/bin/zsh') - - const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') - - expect(zshenv).toContain('builtin source -- "$_orca_user_zshenv"') - // Every function the hook needs is defined above the source, so a user - // `emulate sh` cannot leave the rest of this file unparseable. - expect(zshenv.indexOf('__orca_deferred_init() {')).toBeLessThan( - zshenv.indexOf('builtin source -- "$_orca_user_zshenv"') - ) - }) }) diff --git a/src/main/daemon/windows-conpty-warmup.ts b/src/main/daemon/windows-conpty-warmup.ts index 98c3144380ce..44c34c184e28 100644 --- a/src/main/daemon/windows-conpty-warmup.ts +++ b/src/main/daemon/windows-conpty-warmup.ts @@ -1,32 +1,12 @@ import os from 'node:os' import type * as pty from 'node-pty' import { createRequire } from 'node:module' -import { canUseBunPty, spawnBunPty } from './pty-subprocess/bun-pty-process' import { assignHostProcessToKillOnCloseJob } from '../windows/windows-pty-job' const WARMUP_KILL_TIMEOUT_MS = 10_000 const requireFromMain = createRequire(__filename) const spawnWarmupPty: typeof pty.spawn = (file, args, options) => { - if (canUseBunPty()) { - if (!Array.isArray(args)) { - throw new Error('Bun PTY requires argument arrays') - } - const env: Record = {} - for (const [key, value] of Object.entries(options.env ?? process.env)) { - if (value !== undefined) { - env[key] = value - } - } - return spawnBunPty({ - file, - args, - cwd: options.cwd ?? os.homedir(), - env, - cols: options.cols ?? 2, - rows: options.rows ?? 1 - }) - } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: node-pty's installed package implements the declared spawn contract. const nodePty = requireFromMain('node-pty') as typeof pty return nodePty.spawn(file, args, options) @@ -47,9 +27,7 @@ export function warmWindowsConptyOnce(spawnPty: typeof pty.spawn = spawnWarmupPt setImmediate(() => { try { // Warm-up children must die with the daemon, even before its first real terminal. - if (!canUseBunPty()) { - assignHostProcessToKillOnCloseJob() - } + assignHostProcessToKillOnCloseJob() const proc = spawnPty(process.env.COMSPEC || 'cmd.exe', ['/c', 'exit'], { name: 'xterm-256color', cols: 2, diff --git a/src/main/emulator/serve-sim-execution.ts b/src/main/emulator/serve-sim-execution.ts index 08e5e1c19841..99ceeb310440 100644 --- a/src/main/emulator/serve-sim-execution.ts +++ b/src/main/emulator/serve-sim-execution.ts @@ -1,13 +1,5 @@ import { runProcess } from '../../shared/child-process/run-process' -import { - accessSync, - chmodSync, - constants, - existsSync, - mkdirSync, - readFileSync, - writeFileSync -} from 'node:fs' +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { app } from 'electron' import { platform, tmpdir } from 'node:os' import { delimiter, join } from 'node:path' @@ -125,14 +117,6 @@ export function resolveServeSimExecutable(): ServeSimExecutable { const nodeModulesPackageDir = join(app.getAppPath(), 'node_modules', 'serve-sim') const nodeModulesEntry = join(nodeModulesPackageDir, 'dist', 'serve-sim.js') if (existsSync(nodeModulesEntry)) { - const helperBin = join(nodeModulesPackageDir, 'bin', 'serve-sim-bin') - if (existsSync(helperBin) && process.platform !== 'win32') { - try { - accessSync(helperBin, constants.X_OK) - } catch { - chmodSync(helperBin, 0o755) - } - } return { command: process.execPath, baseArgs: [nodeModulesEntry], usesElectronAsNode: true } } diff --git a/src/main/emulator/serve-sim-helper-processes.test.ts b/src/main/emulator/serve-sim-helper-processes.test.ts index ef9673e2700c..275961a90578 100644 --- a/src/main/emulator/serve-sim-helper-processes.test.ts +++ b/src/main/emulator/serve-sim-helper-processes.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { parseServeSimHelperProcesses } from './serve-sim-helper-processes' describe('parseServeSimHelperProcesses', () => { - it('returns exact serve-sim-bin helper processes from ps output', () => { + it('returns legacy serve-sim-bin helper processes from ps output', () => { const psOutput = ` 101 /Applications/serve-sim/bin/serve-sim-bin UDID-1 --port 3100 102 /Applications/serve-sim/bin/serve-sim UDID-1 --port 3100 @@ -22,6 +22,29 @@ describe('parseServeSimHelperProcesses', () => { ]) }) + it('returns node-hosted serve-sim helpers and skips one-shot serve-sim commands', () => { + const runtime = '/Users/me/Library/Application Support/Orca/serve-sim-runtime/1.4.0' + const psOutput = ` + 301 /Applications/Orca.app/Contents/MacOS/Orca ${runtime}/dist/serve-sim.js UDID-1 --port 3100 --host 127.0.0.1 --exit-on-simulator-shutdown + 302 /Applications/Orca.app/Contents/MacOS/Orca ${runtime}/dist/serve-sim.js tap 0.5 0.5 -d UDID-1 + 303 /Applications/Orca.app/Contents/MacOS/Orca ${runtime}/dist/serve-sim.js --detach -q UDID-1 + 304 /usr/local/bin/serve-sim UDID-2 --port 3101 --host 127.0.0.1 --exit-on-simulator-shutdown + 305 node /tmp/not-serve-sim.js UDID-3 --exit-on-simulator-shutdown + ` + + expect(parseServeSimHelperProcesses(psOutput)).toEqual([ + { + pid: 301, + command: `/Applications/Orca.app/Contents/MacOS/Orca ${runtime}/dist/serve-sim.js UDID-1 --port 3100 --host 127.0.0.1 --exit-on-simulator-shutdown` + }, + { + pid: 304, + command: + '/usr/local/bin/serve-sim UDID-2 --port 3101 --host 127.0.0.1 --exit-on-simulator-shutdown' + } + ]) + }) + it('scans ps output without line-array splitting', () => { const splitSpy = vi.spyOn(String.prototype, 'split') try { diff --git a/src/main/emulator/serve-sim-helper-processes.ts b/src/main/emulator/serve-sim-helper-processes.ts index 9bcf14b6d9b9..77ea809f6ef4 100644 --- a/src/main/emulator/serve-sim-helper-processes.ts +++ b/src/main/emulator/serve-sim-helper-processes.ts @@ -8,6 +8,8 @@ export type ServeSimHelperProcess = { command: string } +const SERVE_SIM_DETACHED_HELPER_FLAG = '--exit-on-simulator-shutdown' + type ServeSimHelperProcessLookupOptions = { helperPid?: number includeOrphaned?: boolean @@ -25,6 +27,17 @@ function execFileText(command: string, args: string[]): Promise { }) } +// Why: serve-sim >= 0.1.47 hosts the helper in node; keep serve-sim-bin so a pre-update helper is still reaped. +function isServeSimHelperCommand(command: string): boolean { + if (/(^|\/)serve-sim-bin(?:\s|$)/.test(command)) { + return true + } + return ( + commandContainsToken(command, SERVE_SIM_DETACHED_HELPER_FLAG) && + /(^|\/)serve-sim(?:\.js)?\s/.test(command) + ) +} + export function parseServeSimHelperProcesses(psOutput: string): ServeSimHelperProcess[] { const helpers: ServeSimHelperProcess[] = [] for (const line of iterateProcessOutputLines(psOutput)) { @@ -34,7 +47,7 @@ export function parseServeSimHelperProcesses(psOutput: string): ServeSimHelperPr } const pid = Number(match[1]) const command = match[2] ?? '' - if (!Number.isInteger(pid) || !/(^|\/)serve-sim-bin(?:\s|$)/.test(command)) { + if (!Number.isInteger(pid) || !isServeSimHelperCommand(command)) { continue } helpers.push({ pid, command }) diff --git a/src/main/emulator/serve-sim-runtime-materializer.test.ts b/src/main/emulator/serve-sim-runtime-materializer.test.ts index 538cc833bbc3..1b8d3b6182fa 100644 --- a/src/main/emulator/serve-sim-runtime-materializer.test.ts +++ b/src/main/emulator/serve-sim-runtime-materializer.test.ts @@ -1,27 +1,48 @@ -import { mkdirSync, writeFileSync } from 'node:fs' -import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { cpSync, mkdirSync, readdirSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs' +import { + mkdtemp, + mkdir, + readFile, + readdir, + readlink, + rename, + rm, + stat, + writeFile +} from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' import { materializeServeSimRuntime } from './serve-sim-runtime-materializer' const DYLIB_CONTENT = Buffer.from('signed-simcam-dylib-mach-o-bytes') async function createBundledServeSimPackage(root: string): Promise { - const packageDir = join(root, 'bundled-serve-sim') + const nodeModulesDir = join(root, 'bundle', 'node_modules') + const packageDir = join(nodeModulesDir, 'serve-sim') await mkdir(join(packageDir, 'dist', 'simcam'), { recursive: true }) - await mkdir(join(packageDir, 'bin'), { recursive: true }) + await mkdir(join(nodeModulesDir, 'ws'), { recursive: true }) + await writeFile(join(nodeModulesDir, 'ws', 'package.json'), '{"name":"ws"}') + await writeFile( + join(packageDir, 'package.json'), + JSON.stringify({ name: 'serve-sim', dependencies: { ws: '^8', 'not-bundled': '^1' } }) + ) await writeFile(join(packageDir, 'dist', 'serve-sim.js'), 'console.log("serve-sim")') await writeFile(join(packageDir, 'dist', 'simcam', 'libSimCameraInjector.dylib'), DYLIB_CONTENT, { mode: 0o644 }) await writeFile(join(packageDir, 'dist', 'simcam', 'serve-sim-camera-helper'), 'helper', { - mode: 0o644 + mode: 0o755 }) - await writeFile(join(packageDir, 'bin', 'serve-sim-bin'), 'bin', { mode: 0o644 }) return packageDir } +function symlinkDir(target: string, path: string): void { + // 'junction' lets Windows create the link without symlink privilege; POSIX ignores it. + symlinkSync(target, path, 'junction') +} + describe('materializeServeSimRuntime', () => { const cleanupPaths: string[] = [] @@ -49,19 +70,15 @@ describe('materializeServeSimRuntime', () => { clearQuarantine }) - expect(materialized).toBe(join(root, 'runtime', '1.2.3')) + expect(materialized).toBe(join(root, 'runtime', '1.2.3', 'node_modules', 'serve-sim')) // The dylib must be byte-identical to the bundled (Developer-ID-signed) copy. const dylibPath = join(materialized!, 'dist', 'simcam', 'libSimCameraInjector.dylib') expect(await readFile(dylibPath)).toEqual(DYLIB_CONTENT) expect(clearQuarantine).toHaveBeenCalledTimes(1) expect(clearQuarantine).toHaveBeenCalledWith(expect.stringContaining('.staging-1.2.3-')) if (process.platform !== 'win32') { - for (const executable of [ - join(materialized!, 'bin', 'serve-sim-bin'), - join(materialized!, 'dist', 'simcam', 'serve-sim-camera-helper') - ]) { - expect(((await stat(executable)).mode & 0o111) !== 0).toBe(true) - } + const helper = join(materialized!, 'dist', 'simcam', 'serve-sim-camera-helper') + expect(((await stat(helper)).mode & 0o111) !== 0).toBe(true) } }) @@ -97,7 +114,7 @@ describe('materializeServeSimRuntime', () => { clearQuarantine: () => {} }) - expect(materialized).toBe(join(targetRootDir, '1.2.3')) + expect(materialized).toBe(join(targetRootDir, '1.2.3', 'node_modules', 'serve-sim')) await expect(stat(join(targetRootDir, '1.0.0'))).rejects.toThrow() }) @@ -105,22 +122,24 @@ describe('materializeServeSimRuntime', () => { const root = await createRoot() const bundledPackageDir = await createBundledServeSimPackage(root) const targetRootDir = join(root, 'runtime') - const targetDir = join(targetRootDir, '1.2.3') + const winnerNodeModulesDir = join(targetRootDir, '1.2.3', 'node_modules') + const winnerPackageDir = join(winnerNodeModulesDir, 'serve-sim') // Simulate another instance finishing first: right before our rename, drop a - // complete target dir in place so renameSync fails but the entry exists. + // complete target dir in place so renameSync fails but the runtime is current. const materialized = materializeServeSimRuntime({ bundledPackageDir, targetRootDir, version: '1.2.3', clearQuarantine: () => { - mkdirSync(join(targetDir, 'dist'), { recursive: true }) - writeFileSync(join(targetDir, 'dist', 'serve-sim.js'), 'winner') + mkdirSync(join(winnerPackageDir, 'dist'), { recursive: true }) + writeFileSync(join(winnerPackageDir, 'dist', 'serve-sim.js'), 'winner') + symlinkDir(join(root, 'bundle', 'node_modules', 'ws'), join(winnerNodeModulesDir, 'ws')) } }) - expect(materialized).toBe(targetDir) - expect(await readFile(join(targetDir, 'dist', 'serve-sim.js'), 'utf8')).toBe('winner') + expect(materialized).toBe(winnerPackageDir) + expect(await readFile(join(winnerPackageDir, 'dist', 'serve-sim.js'), 'utf8')).toBe('winner') const leftovers = (await readdir(targetRootDir)).filter((name) => name.startsWith('.staging')) expect(leftovers).toEqual([]) }) @@ -157,4 +176,121 @@ describe('materializeServeSimRuntime', () => { expect(materialized).toBeNull() }) + + it('links each bundled dependency next to the copy and skips ones the bundle lacks', async () => { + const root = await createRoot() + const bundledPackageDir = await createBundledServeSimPackage(root) + + const materialized = materializeServeSimRuntime({ + bundledPackageDir, + targetRootDir: join(root, 'runtime'), + version: '1.2.3', + clearQuarantine: () => {} + }) + + const nodeModulesDir = join(root, 'runtime', '1.2.3', 'node_modules') + expect(materialized).toBe(join(nodeModulesDir, 'serve-sim')) + expect(realpathSync(join(nodeModulesDir, 'ws'))).toBe( + realpathSync(join(root, 'bundle', 'node_modules', 'ws')) + ) + expect((await readdir(nodeModulesDir)).sort()).toEqual(['serve-sim', 'ws']) + }) + + it('clears quarantine on the copied package before linking into the bundle', async () => { + const root = await createRoot() + const bundledPackageDir = await createBundledServeSimPackage(root) + const seenEntries: string[][] = [] + + materializeServeSimRuntime({ + bundledPackageDir, + targetRootDir: join(root, 'runtime'), + version: '1.2.3', + clearQuarantine: (dir) => { + seenEntries.push([dir, ...readdirSync(join(dir, '..'))]) + } + }) + + expect(seenEntries).toHaveLength(1) + expect(seenEntries[0][0]).toMatch(/\.staging-1\.2\.3-\d+[/\\]node_modules[/\\]serve-sim$/) + expect(seenEntries[0].slice(1)).toEqual(['serve-sim']) + }) + + it('rebuilds a same-version runtime left in the old flat layout', async () => { + const root = await createRoot() + const bundledPackageDir = await createBundledServeSimPackage(root) + const targetRootDir = join(root, 'runtime') + await mkdir(join(targetRootDir, '1.2.3', 'dist'), { recursive: true }) + await writeFile(join(targetRootDir, '1.2.3', 'dist', 'serve-sim.js'), 'old layout') + + const materialized = materializeServeSimRuntime({ + bundledPackageDir, + targetRootDir, + version: '1.2.3', + clearQuarantine: () => {} + }) + + expect(materialized).toBe(join(targetRootDir, '1.2.3', 'node_modules', 'serve-sim')) + await expect(stat(join(targetRootDir, '1.2.3', 'dist'))).rejects.toThrow() + }) + + it('rebuilds when the app moved and the dependency links dangle', async () => { + const root = await createRoot() + const bundledPackageDir = await createBundledServeSimPackage(root) + const targetRootDir = join(root, 'runtime') + materializeServeSimRuntime({ + bundledPackageDir, + targetRootDir, + version: '1.2.3', + clearQuarantine: () => {} + }) + await rename(join(root, 'bundle'), join(root, 'moved-bundle')) + const movedPackageDir = join(root, 'moved-bundle', 'node_modules', 'serve-sim') + + const materialized = materializeServeSimRuntime({ + bundledPackageDir: movedPackageDir, + targetRootDir, + version: '1.2.3', + clearQuarantine: () => {} + }) + + expect(materialized).toBe(join(targetRootDir, '1.2.3', 'node_modules', 'serve-sim')) + expect(await readlink(join(targetRootDir, '1.2.3', 'node_modules', 'ws'))).toContain( + 'moved-bundle' + ) + }) + + // Regression: serve-sim 0.1.47's ESM entry imports `ws`, which a bare package copy cannot resolve. + it('lets the real serve-sim entry resolve every declared dependency from the copy', async () => { + const root = await createRoot() + const installedPackageDir = realpathSync(join(process.cwd(), 'node_modules', 'serve-sim')) + const manifest = JSON.parse(await readFile(join(installedPackageDir, 'package.json'), 'utf8')) + const dependencyNames = Object.keys(manifest.dependencies ?? {}) + expect(dependencyNames).toContain('ws') + // Mirror the packaged app: a real serve-sim dir with its dependencies hoisted beside it. + const bundledNodeModulesDir = join(root, 'Resources', 'node_modules') + const bundledPackageDir = join(bundledNodeModulesDir, 'serve-sim') + cpSync(installedPackageDir, bundledPackageDir, { recursive: true }) + for (const name of dependencyNames) { + symlinkDir(join(installedPackageDir, '..', name), join(bundledNodeModulesDir, name)) + } + + const materialized = materializeServeSimRuntime({ + bundledPackageDir, + targetRootDir: join(root, 'runtime'), + version: '1.2.3', + clearQuarantine: () => {} + }) + expect(materialized).not.toBeNull() + + // Probe from the entry's own directory so ESM resolution walks up exactly as serve-sim.js does. + const probePath = join(materialized!, 'dist', 'orca-dependency-probe.mjs') + await writeFile( + probePath, + `for (const name of ${JSON.stringify(dependencyNames)}) import.meta.resolve(name)\n` + ) + const result = await runProcess({ program: process.execPath, args: [probePath] }) + + expect(result.stderr).toBe('') + expect(result.code).toBe(0) + }) }) diff --git a/src/main/emulator/serve-sim-runtime-materializer.ts b/src/main/emulator/serve-sim-runtime-materializer.ts index 26abff4a6be0..a890fe250786 100644 --- a/src/main/emulator/serve-sim-runtime-materializer.ts +++ b/src/main/emulator/serve-sim-runtime-materializer.ts @@ -1,6 +1,16 @@ import { execFileSync } from 'node:child_process' -import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from 'node:fs' -import { join } from 'node:path' +import { + cpSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + symlinkSync +} from 'node:fs' +import { dirname, join } from 'node:path' export type ServeSimRuntimeMaterializerOptions = { bundledPackageDir: string @@ -9,11 +19,6 @@ export type ServeSimRuntimeMaterializerOptions = { clearQuarantine?: (dir: string) => void } -const EXECUTABLE_RELATIVE_PATHS = [ - join('bin', 'serve-sim-bin'), - join('dist', 'simcam', 'serve-sim-camera-helper') -] - function defaultClearQuarantine(dir: string): void { if (process.platform !== 'darwin') { return @@ -47,45 +52,89 @@ function pruneStaleServeSimRuntimes(targetRootDir: string, keepVersion: string): } } +function readDependencyNames(packageDir: string): string[] { + const manifest: unknown = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) + if (!manifest || typeof manifest !== 'object' || !('dependencies' in manifest)) { + return [] + } + const { dependencies } = manifest + return dependencies && typeof dependencies === 'object' ? Object.keys(dependencies) : [] +} + +// Why: the copy has no node_modules of its own, so its bare imports (e.g. `ws`) must resolve +// through links to the bundle's installed siblings; a dangling link (moved or translocated app) is stale. +function bundledDependencyLinks( + bundledPackageDir: string, + runtimeNodeModulesDir: string +): { linkPath: string; targetPath: string }[] { + const bundledNodeModulesDir = dirname(realpathSync(bundledPackageDir)) + return readDependencyNames(bundledPackageDir) + .map((name) => ({ + linkPath: join(runtimeNodeModulesDir, name), + targetPath: join(bundledNodeModulesDir, name) + })) + .filter(({ targetPath }) => existsSync(targetPath)) +} + +function isMaterializedRuntimeCurrent(bundledPackageDir: string, versionDir: string): boolean { + const nodeModulesDir = join(versionDir, 'node_modules') + if (!existsSync(join(nodeModulesDir, 'serve-sim', 'dist', 'serve-sim.js'))) { + return false + } + try { + return bundledDependencyLinks(bundledPackageDir, nodeModulesDir).every( + ({ linkPath, targetPath }) => realpathSync(linkPath) === realpathSync(targetPath) + ) + } catch { + return false + } +} + // Copies the bundled serve-sim package to a per-version directory outside the // signed app bundle and strips quarantine, so the camera dylib injected from // it is not subject to Gatekeeper assessment. The bundled dylib stays signed // and in place (it must, or the app fails notarization) — this only relocates // the copy that actually gets DYLD-injected. serve-sim resolves the dylib and // helper relative to its own entry, so the whole package moves together. +// Layout: /node_modules/serve-sim plus links to its bundled dependencies. export function materializeServeSimRuntime( options: ServeSimRuntimeMaterializerOptions ): string | null { const { bundledPackageDir, targetRootDir, version } = options const clearQuarantine = options.clearQuarantine ?? defaultClearQuarantine const targetDir = join(targetRootDir, version) - const entryPath = join(targetDir, 'dist', 'serve-sim.js') - if (existsSync(entryPath)) { - return targetDir + const packageDir = join(targetDir, 'node_modules', 'serve-sim') + if (isMaterializedRuntimeCurrent(bundledPackageDir, targetDir)) { + return packageDir } const stagingDir = join(targetRootDir, `.staging-${version}-${process.pid}`) + const stagingNodeModulesDir = join(stagingDir, 'node_modules') try { mkdirSync(targetRootDir, { recursive: true }) pruneStaleServeSimRuntimes(targetRootDir, version) rmSync(stagingDir, { recursive: true, force: true }) rmSync(targetDir, { recursive: true, force: true }) - cpSync(bundledPackageDir, stagingDir, { recursive: true }) - for (const relativePath of EXECUTABLE_RELATIVE_PATHS) { - const executablePath = join(stagingDir, relativePath) - if (existsSync(executablePath)) { - chmodSync(executablePath, 0o755) - } + const stagingPackageDir = join(stagingNodeModulesDir, 'serve-sim') + // Why realpath: cpSync copies a symlinked package dir (pnpm layout) as a link back into the bundle. + cpSync(realpathSync(bundledPackageDir), stagingPackageDir, { recursive: true }) + // Why before linking: the recursive xattr walk must not reach into the signed bundle. + clearQuarantine(stagingPackageDir) + for (const { linkPath, targetPath } of bundledDependencyLinks( + bundledPackageDir, + stagingNodeModulesDir + )) { + mkdirSync(dirname(linkPath), { recursive: true }) + symlinkSync(targetPath, linkPath, 'junction') } - clearQuarantine(stagingDir) try { renameSync(stagingDir, targetDir) } catch (error) { // Another app instance sharing userData may have finished first. - if (!existsSync(entryPath)) { + if (!isMaterializedRuntimeCurrent(bundledPackageDir, targetDir)) { throw error } } - return existsSync(entryPath) ? targetDir : null + return isMaterializedRuntimeCurrent(bundledPackageDir, targetDir) ? packageDir : null } catch { return null } finally { diff --git a/src/main/git/add-sparse-worktree.test.ts b/src/main/git/add-sparse-worktree.test.ts index 2541b822438e..987fcb5814c7 100644 --- a/src/main/git/add-sparse-worktree.test.ts +++ b/src/main/git/add-sparse-worktree.test.ts @@ -7,26 +7,14 @@ const { translateWslOutputPathsMock, statMock, readFileMock, - resolveGitDirMock, - moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletionMock + resolveGitDirMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), gitExecFileSyncMock: vi.fn(), translateWslOutputPathsMock: vi.fn((output: string) => output), statMock: vi.fn(), readFileMock: vi.fn(), - resolveGitDirMock: vi.fn(), - moveWorktreeDirectoryToTrashMock: vi.fn(), - restoreWorktreeDirectoryFromTrashMock: vi.fn(), - scheduleWorktreeTrashDeletionMock: vi.fn() -})) - -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrash: restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletion: scheduleWorktreeTrashDeletionMock + resolveGitDirMock: vi.fn() })) vi.mock('./runner', () => ({ @@ -59,11 +47,7 @@ const mockGitCommands = createGitCommandMocker(gitExecFileAsyncMock) const getGitCalls = createGitCallReader(gitExecFileAsyncMock) beforeEach(() => { - resetWorktreeRemovalState({ - moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletionMock - }) + resetWorktreeRemovalState() }) describe('addSparseWorktree', () => { diff --git a/src/main/git/command-runner/git-admission-state.ts b/src/main/git/command-runner/git-admission-state.ts index 60b198660cbd..a9a9ce78ddde 100644 --- a/src/main/git/command-runner/git-admission-state.ts +++ b/src/main/git/command-runner/git-admission-state.ts @@ -8,7 +8,8 @@ export const NETWORK_HEADROOM = 1 export const ROUTE_CAP = 2 export const ROUTE_HEADROOM = 1 export const GIT_ADMISSION_AGING_MS = 15_000 -// General 4+2 and network 3+1 are disjoint, so at most ten git children run globally. +// General 4+2 and network 3+1 are disjoint, so at most ten admitted git children run globally; +// worktree deletes bypass admission under their own limit (WORKTREE_DELETE_CONCURRENCY). export const MAX_GIT_CHILDREN = 10 export type AdmissionClass = 'general' | 'network' diff --git a/src/main/git/command-runner/git-exec-admission-exempt.test.ts b/src/main/git/command-runner/git-exec-admission-exempt.test.ts new file mode 100644 index 000000000000..5bbd04cf4479 --- /dev/null +++ b/src/main/git/command-runner/git-exec-admission-exempt.test.ts @@ -0,0 +1,62 @@ +import { EventEmitter } from 'node:events' +import type { ChildProcess } from 'node:child_process' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() })) + +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal()), + execFile: execFileMock +})) + +import { gitExecFileAsync } from './git-exec-file' +import { + GitAdmissionScheduler, + _gitAdmissionSnapshotForTests, + _resetGitAdmissionForTests +} from './git-subprocess-admission' + +function mockChild(pid: number): ChildProcess { + const child = Object.assign(new EventEmitter(), { + pid, + kill: vi.fn(() => true), + stdin: Object.assign(new EventEmitter(), { end: vi.fn() }), + stdout: new EventEmitter(), + stderr: new EventEmitter() + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the exec wrapper only reads pid, kill, stdio and events. + return child as unknown as ChildProcess +} + +describe('worktree deletes and general git admission', () => { + beforeEach(() => { + vi.useFakeTimers() + execFileMock.mockReset() + // The smallest general cap a host can have: deletes holding it would stall every status read. + _resetGitAdmissionForTests(new GitAdmissionScheduler({ generalCap: 2, generalHeadroom: 0 })) + }) + + afterEach(() => { + vi.useRealTimers() + _resetGitAdmissionForTests() + }) + + it('runs a status read while deletes that fill the general cap are still running', async () => { + let pid = 100 + execFileMock.mockImplementation(() => mockChild(pid++)) + + for (const path of ['/wt-a', '/wt-b', '/wt-c']) { + void gitExecFileAsync(['worktree', 'remove', path], { cwd: '/repo', admissionExempt: true }) + } + await vi.advanceTimersByTimeAsync(0) + expect(execFileMock).toHaveBeenCalledTimes(3) + expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed ?? 0).toBe(0) + + void gitExecFileAsync(['status', '--porcelain'], { cwd: '/repo' }) + await vi.advanceTimersByTimeAsync(0) + + expect(execFileMock).toHaveBeenCalledTimes(4) + expect(execFileMock.mock.calls[3]?.[1]).toContain('status') + expect(_gitAdmissionSnapshotForTests().queued).toBe(0) + }) +}) diff --git a/src/main/git/command-runner/git-exec-file.ts b/src/main/git/command-runner/git-exec-file.ts index acfeb9f8a3e5..13027d395b25 100644 --- a/src/main/git/command-runner/git-exec-file.ts +++ b/src/main/git/command-runner/git-exec-file.ts @@ -65,13 +65,15 @@ async function gitExecFileAsyncUnlocked( const policy = effectiveOptions.useConfiguredSshCommandForNetwork ? await buildNetworkSshPolicyEnv(effectiveOptions) : { env: nonInteractiveGitEnv(effectiveOptions.env), mode: 'default' as const } - const grant = await acquireGitAdmission({ - args, - cwd: options.cwd, - wslDistro: options.wslDistro, - tier: options.admissionTier, - signal: options.signal - }) + const grant = options.admissionExempt + ? { queueWaitMs: 0, release: () => {} } + : await acquireGitAdmission({ + args, + cwd: options.cwd, + wslDistro: options.wslDistro, + tier: options.admissionTier, + signal: options.signal + }) span?.setAttribute('git.queue_wait_ms', grant.queueWaitMs) const timeoutMs = gitCommandTimeoutMs(args, options.timeout, options.timeoutMsForTest) const terminationState: { current: Promise | null } = { current: null } diff --git a/src/main/git/command-runner/git-exec-options.ts b/src/main/git/command-runner/git-exec-options.ts index 75ced0d30302..93ad47b6520d 100644 --- a/src/main/git/command-runner/git-exec-options.ts +++ b/src/main/git/command-runner/git-exec-options.ts @@ -23,4 +23,6 @@ export type GitExecOptions = { captureWslLoginShellOutput?: boolean /** Scheduler priority for this child; status is the safe default. */ admissionTier?: GitAdmissionTier + /** Skips general admission; only for a caller that bounds its own concurrency (worktree deletes). */ + admissionExempt?: true } diff --git a/src/main/git/exact-ref-probe.ts b/src/main/git/exact-ref-probe.ts index ce89fac3f69c..b8857a3c1f8f 100644 --- a/src/main/git/exact-ref-probe.ts +++ b/src/main/git/exact-ref-probe.ts @@ -1,4 +1,7 @@ import { isSafeGitRefName } from '../../shared/git-status-upstream-ref' +import { isShowRefNoMatchError } from '../../shared/git-show-ref-no-match' + +export { isShowRefNoMatchError } export type ExactRefProbeExecOptions = { maxBuffer?: number @@ -22,23 +25,6 @@ const EXACT_REF_PROBE_CONCURRENCY = 8 // SHA-1 and SHA-256 repositories both report a full object id here. const OBJECT_ID_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/ -export function isShowRefNoMatchError(error: unknown): boolean { - const record = error && typeof error === 'object' ? (error as Record) : undefined - // Git reports a missing ref as numeric exit status 1. Keep string-valued - // transport/error codes (including a relay that happens to use `"1"`) in - // the unknown bucket so SSH loss cannot look like an absent ref. - if (record?.code !== 1) { - return false - } - // `--quiet` makes Git print nothing for a missing ref, but a wrapper that - // also exits 1 always explains itself: `wsl.exe` on a dead distro, a relay - // transport error. Empty stderr is what separates proven absence from a - // probe that never ran. A runner that reports no stderr at all (the SSH - // provider) keeps its existing exit-code contract. - const stderr = record.stderr - return stderr === undefined || stderr === null || String(stderr).trim().length === 0 -} - function commandOptions(options: ExactRefProbeExecOptions): ExactRefProbeExecOptions | undefined { if (options.maxBuffer === undefined && options.timeoutMs === undefined) { return undefined diff --git a/src/main/git/remove-worktree-branch-cleanup.test.ts b/src/main/git/remove-worktree-branch-cleanup.test.ts index 6c4885bca75d..6c31b5f393b9 100644 --- a/src/main/git/remove-worktree-branch-cleanup.test.ts +++ b/src/main/git/remove-worktree-branch-cleanup.test.ts @@ -1,5 +1,5 @@ import type * as FsPromises from 'node:fs/promises' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' const { gitExecFileAsyncMock, @@ -7,26 +7,14 @@ const { translateWslOutputPathsMock, statMock, readFileMock, - resolveGitDirMock, - moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletionMock + resolveGitDirMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), gitExecFileSyncMock: vi.fn(), translateWslOutputPathsMock: vi.fn((output: string) => output), statMock: vi.fn(), readFileMock: vi.fn(), - resolveGitDirMock: vi.fn(), - moveWorktreeDirectoryToTrashMock: vi.fn(), - restoreWorktreeDirectoryFromTrashMock: vi.fn(), - scheduleWorktreeTrashDeletionMock: vi.fn() -})) - -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrash: restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletion: scheduleWorktreeTrashDeletionMock + resolveGitDirMock: vi.fn() })) vi.mock('./runner', () => ({ @@ -58,12 +46,16 @@ import { forceDeleteLocalBranch, removeWorktree } from './worktree' const mockGitCommands = createGitCommandMocker(gitExecFileAsyncMock) const getGitCalls = createGitCallReader(gitExecFileAsyncMock) +// Why: removal argv carries core.longpaths on Windows; pin a non-Windows default for exact argv. +let platformSpy: MockInstance<() => NodeJS.Platform> + beforeEach(() => { - resetWorktreeRemovalState({ - moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletionMock - }) + resetWorktreeRemovalState() + platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') +}) + +afterEach(() => { + platformSpy.mockRestore() }) describe('removeWorktree', () => { @@ -460,4 +452,32 @@ branch refs/heads/feature/test ) expect(getGitCalls()).not.toContain('git config --remove-section branch.feature/test') }) + + it('runs branch cleanup for one repo one removal at a time, not the checkout deletes', async () => { + let releaseFirstBranchDelete: () => void = () => {} + const firstBranchDelete = new Promise((resolve) => { + releaseFirstBranchDelete = resolve + }) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.join(' ') === 'branch -d -- feature/a') { + await firstBranchDelete + } + return { stdout: '', stderr: '' } + }) + + const first = removeWorktree('/repo', '/repo-a', false, { + knownRemovedWorktree: { branch: 'refs/heads/feature/a', head: 'aaa', locked: false } + }) + await vi.waitFor(() => expect(getGitCalls()).toContain('git branch -d -- feature/a')) + const second = removeWorktree('/repo', '/repo-b', false, { + knownRemovedWorktree: { branch: 'refs/heads/feature/b', head: 'bbb', locked: false } + }) + await vi.waitFor(() => expect(getGitCalls()).toContain('git worktree remove /repo-b')) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(getGitCalls()).not.toContain('git branch -d -- feature/b') + + releaseFirstBranchDelete() + await Promise.all([first, second]) + expect(getGitCalls()).toContain('git branch -d -- feature/b') + }) }) diff --git a/src/main/git/remove-worktree-clean-preflight.test.ts b/src/main/git/remove-worktree-clean-preflight.test.ts index 190074ed1517..ebaec39cee43 100644 --- a/src/main/git/remove-worktree-clean-preflight.test.ts +++ b/src/main/git/remove-worktree-clean-preflight.test.ts @@ -7,26 +7,14 @@ const { translateWslOutputPathsMock, statMock, readFileMock, - resolveGitDirMock, - moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletionMock + resolveGitDirMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), gitExecFileSyncMock: vi.fn(), translateWslOutputPathsMock: vi.fn((output: string) => output), statMock: vi.fn(), readFileMock: vi.fn(), - resolveGitDirMock: vi.fn(), - moveWorktreeDirectoryToTrashMock: vi.fn(), - restoreWorktreeDirectoryFromTrashMock: vi.fn(), - scheduleWorktreeTrashDeletionMock: vi.fn() -})) - -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrash: restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletion: scheduleWorktreeTrashDeletionMock + resolveGitDirMock: vi.fn() })) vi.mock('./runner', () => ({ @@ -50,11 +38,7 @@ import { resetWorktreeRemovalState } from './remove-worktree-test-harness' import { assertWorktreeCleanForRemoval, WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS } from './worktree' beforeEach(() => { - resetWorktreeRemovalState({ - moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletionMock - }) + resetWorktreeRemovalState() }) describe('assertWorktreeCleanForRemoval', () => { diff --git a/src/main/git/remove-worktree-test-harness.ts b/src/main/git/remove-worktree-test-harness.ts index 1f8be9b7fcc9..509c6b580eae 100644 --- a/src/main/git/remove-worktree-test-harness.ts +++ b/src/main/git/remove-worktree-test-harness.ts @@ -80,23 +80,11 @@ export function mockSparseCheckoutEnabledConfig(readFileMock: Mock): void { }) } -export type WorktreeTrashMocks = { - moveWorktreeDirectoryToTrashMock: Mock - restoreWorktreeDirectoryFromTrashMock: Mock - scheduleWorktreeTrashDeletionMock: Mock -} - -/** Per-test reset shared by every worktree suite: caches cleared, trash rename unavailable. */ -export function resetWorktreeRemovalState(trashMocks: WorktreeTrashMocks): void { +/** Per-test reset shared by every worktree suite: caches cleared. */ +export function resetWorktreeRemovalState(): void { clearGitCapabilityStateForTests() _resetWorktreeScanCacheForTests() __resetSparseCheckoutStateCacheForTests() - // Default: the checkout cannot be renamed aside, so removal deletes it in place. - trashMocks.moveWorktreeDirectoryToTrashMock.mockReset() - trashMocks.moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined) - trashMocks.restoreWorktreeDirectoryFromTrashMock.mockReset() - trashMocks.restoreWorktreeDirectoryFromTrashMock.mockResolvedValue(true) - trashMocks.scheduleWorktreeTrashDeletionMock.mockReset() } export type WorktreeGitMocks = { diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index 006ee3087bb1..d2246da56ad0 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -1,5 +1,9 @@ +import { existsSync } from 'node:fs' import type * as FsPromises from 'node:fs/promises' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' const { gitExecFileAsyncMock, @@ -7,26 +11,14 @@ const { translateWslOutputPathsMock, statMock, readFileMock, - resolveGitDirMock, - moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletionMock + resolveGitDirMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), gitExecFileSyncMock: vi.fn(), translateWslOutputPathsMock: vi.fn((output: string) => output), statMock: vi.fn(), readFileMock: vi.fn(), - resolveGitDirMock: vi.fn(), - moveWorktreeDirectoryToTrashMock: vi.fn(), - restoreWorktreeDirectoryFromTrashMock: vi.fn(), - scheduleWorktreeTrashDeletionMock: vi.fn() -})) - -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrash: restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletion: scheduleWorktreeTrashDeletionMock + resolveGitDirMock: vi.fn() })) vi.mock('./runner', () => ({ @@ -53,7 +45,7 @@ import { resetWorktreeRemovalState } from './remove-worktree-test-harness' -import { removeWorktree, WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS } from './worktree' +import { removeWorktree } from './worktree' import { __getSparseCheckoutStateCacheSizeForTests, detectSparseCheckoutCached @@ -62,12 +54,17 @@ import { const mockGitCommands = createGitCommandMocker(gitExecFileAsyncMock) const getGitCalls = createGitCallReader(gitExecFileAsyncMock) +// Why: removal argv carries core.longpaths on Windows, so pin a non-Windows default or the +// exact-argv assertions below fail for a maintainer running vitest on Windows. +let platformSpy: MockInstance<() => NodeJS.Platform> + beforeEach(() => { - resetWorktreeRemovalState({ - moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletionMock - }) + resetWorktreeRemovalState() + platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') +}) + +afterEach(() => { + platformSpy.mockRestore() }) describe('removeWorktree', () => { @@ -223,8 +220,6 @@ branch refs/heads/main const calls = getGitCalls() expect(calls).toEqual([ 'git worktree list --porcelain -z', - // The cleanliness probe that decides whether the checkout may be renamed aside. - 'git status --porcelain --untracked-files=all', 'git worktree remove /repo-feature', 'git branch -d -- feature/test', 'git worktree prune', @@ -232,7 +227,7 @@ branch refs/heads/main ]) }) - it('renames the checkout aside and deregisters the missing path', async () => { + it('lets Git delete the checkout inline with no deadline', async () => { mockGitCommands({ 'git worktree list --porcelain': { stdout: `worktree /repo @@ -251,24 +246,20 @@ branch refs/heads/main ` } }) - moveWorktreeDirectoryToTrashMock.mockResolvedValue('/trash/wt-1-abcdef01') await removeWorktree('/repo', '/repo-feature') - const calls = getGitCalls() - expect(moveWorktreeDirectoryToTrashMock).toHaveBeenCalledWith('/repo-feature') - expect(scheduleWorktreeTrashDeletionMock).toHaveBeenCalledWith('/trash/wt-1-abcdef01') - expect(calls).toContain('git worktree remove --force /repo-feature') - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['worktree', 'remove', '--force', '/repo-feature'], - { cwd: '/repo', timeout: WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS } - ) - expect(calls).not.toContain('git worktree remove /repo-feature') - expect(calls).not.toContain('git worktree prune') - expect(calls).toContain('git branch -d -- feature/test') + // A large checkout takes Git 30 s or more to delete; any timeout here would cut that short. + // Exempt from general admission: deletes queue under their own limit instead. + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'remove', '/repo-feature'], { + cwd: '/repo', + admissionExempt: true + }) + expect(getGitCalls()).not.toContain('git worktree prune') + expect(getGitCalls()).toContain('git branch -d -- feature/test') }) - it('prunes the registration when deregistering the moved checkout fails', async () => { + it('lets a quit stop only the checkout delete, not the branch cleanup that holds ref locks', async () => { mockGitCommands({ 'git worktree list --porcelain': { stdout: `worktree /repo @@ -285,88 +276,76 @@ branch refs/heads/feature/test HEAD abc123 branch refs/heads/main ` - }, - 'git worktree remove --force /repo-feature': { - error: new Error('fatal: validation failed, cannot remove working directory') } }) - moveWorktreeDirectoryToTrashMock.mockResolvedValue('/trash/wt-2-abcdef02') - - await removeWorktree('/repo', '/repo-feature') + const stop = new AbortController() - expect(getGitCalls()).toContain('git worktree prune') - expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], { - cwd: '/repo', - timeout: WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS + await removeWorktree('/repo', '/repo-feature', false, { + checkoutDeleteSignal: stop.signal }) - expect( - gitExecFileAsyncMock.mock.calls.filter( - ([args, options]) => - args.join(' ') === 'worktree list --porcelain -z' && - options.timeout === WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS - ) - ).toHaveLength(2) - expect(scheduleWorktreeTrashDeletionMock).toHaveBeenCalledWith('/trash/wt-2-abcdef02') - expect(restoreWorktreeDirectoryFromTrashMock).not.toHaveBeenCalled() - }) - it('restores the moved checkout and removes in place when the registration survives pruning', async () => { - mockGitCommands({ - 'git worktree list --porcelain': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main + const optionsOf = (command: string): { signal?: AbortSignal } | undefined => + gitExecFileAsyncMock.mock.calls.find((call) => call[0].join(' ') === command)?.[1] + expect(optionsOf('worktree remove /repo-feature')?.signal).toBe(stop.signal) + expect(optionsOf('branch -d -- feature/test')).toBeDefined() + expect(optionsOf('branch -d -- feature/test')?.signal).toBeUndefined() + }) -worktree /repo-feature -HEAD def456 -branch refs/heads/feature/test -` - }, - 'git worktree remove --force /repo-feature': { - error: new Error('fatal: validation failed, cannot remove working directory') - } - }) - moveWorktreeDirectoryToTrashMock.mockResolvedValue('/trash/wt-3-abcdef03') + it('never lets Git run an inherited yes/no prompt program during the delete', async () => { + vi.stubEnv('GIT_ASK_YESNO', '/usr/local/bin/prompt') + try { + mockGitCommands({}) - await removeWorktree('/repo', '/repo-feature') + await removeWorktree('/repo', '/repo-feature', false, { + knownRemovedWorktree: { branch: '', head: '', locked: false } + }) - expect(restoreWorktreeDirectoryFromTrashMock).toHaveBeenCalledWith( - '/trash/wt-3-abcdef03', - '/repo-feature' - ) - expect(scheduleWorktreeTrashDeletionMock).not.toHaveBeenCalled() - expect(getGitCalls()).toContain('git worktree remove /repo-feature') + const [, removeOptions] = gitExecFileAsyncMock.mock.calls[0] + expect(removeOptions.env).toBeDefined() + expect(removeOptions.env).not.toHaveProperty('GIT_ASK_YESNO') + } finally { + vi.unstubAllEnvs() + } }) - it('removes in place when the checkout cannot be renamed aside', async () => { - mockGitCommands({ - 'git worktree list --porcelain': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main + it('deletes what Git left of the checkout after a successful remove', async () => { + const scratch = await mkdtemp(join(tmpdir(), 'orca-remove-residue-')) + const worktreePath = join(scratch, 'feature') + // Git for Windows leaves junctions and the directories holding them behind yet exits 0. + await mkdir(join(worktreePath, 'node_modules', 'left-behind'), { recursive: true }) + try { + mockGitCommands({}) -worktree /repo-feature -HEAD def456 -branch refs/heads/feature/test -` - }, - 'git worktree list --porcelain#2': { - stdout: `worktree /repo -HEAD abc123 -branch refs/heads/main -` - } - }) - // Windows open handles and cross-volume renames both surface as an unavailable rename. - moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined) + await removeWorktree('/repo', worktreePath, false, { + knownRemovedWorktree: { branch: '', head: '', locked: false } + }) - await removeWorktree('/repo', '/repo-feature') + expect(getGitCalls()).toEqual([`git worktree remove ${worktreePath}`]) + expect(existsSync(worktreePath)).toBe(false) + } finally { + await rm(scratch, { recursive: true, force: true }) + } + }) - expect(getGitCalls()).toContain('git worktree remove /repo-feature') - expect(scheduleWorktreeTrashDeletionMock).not.toHaveBeenCalled() + it('leaves a WSL checkout that Git reported removed to the distro', async () => { + const scratch = await mkdtemp(join(tmpdir(), 'orca-remove-residue-wsl-')) + const worktreePath = join(scratch, 'feature') + await mkdir(worktreePath, { recursive: true }) + try { + mockGitCommands({}) + + await removeWorktree('/repo', worktreePath, false, { + wslDistro: 'Ubuntu', + knownRemovedWorktree: { branch: '', head: '', locked: false } + }) + + expect(existsSync(worktreePath)).toBe(true) + } finally { + await rm(scratch, { recursive: true, force: true }) + } }) - it('never renames a dirty checkout aside', async () => { + it('leaves a dirty checkout to Git to refuse', async () => { mockGitCommands({ 'git worktree list --porcelain': { stdout: `worktree /repo @@ -387,7 +366,7 @@ branch refs/heads/feature/test await expect(removeWorktree('/repo', '/repo-feature')).rejects.toThrow( 'contains modified or untracked files' ) - expect(moveWorktreeDirectoryToTrashMock).not.toHaveBeenCalled() + expect(getGitCalls()).not.toContain('git worktree remove --force /repo-feature') }) it('deletes WSL-hosted checkouts in place inside the distro', async () => { @@ -412,27 +391,20 @@ branch refs/heads/main await removeWorktree('/repo', '/repo-feature', false, { wslDistro: 'Ubuntu' }) - expect(moveWorktreeDirectoryToTrashMock).not.toHaveBeenCalled() expect(getGitCalls()).not.toContain('git status --porcelain --untracked-files=all') expect(getGitCalls()).toContain('git worktree remove /repo-feature') }) - it('does not rename a WSL checkout configured for a native Windows repo', async () => { - const originalPlatform = process.platform + it('removes a WSL checkout configured for a native Windows repo with Git alone', async () => { const worktreePath = '\\\\wsl.localhost\\Ubuntu\\home\\dev\\feature' - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - try { - mockGitCommands({}) + platformSpy.mockReturnValue('win32') + mockGitCommands({}) - await removeWorktree('C:\\repo', worktreePath, false, { - knownRemovedWorktree: { branch: '', head: '', locked: false } - }) + await removeWorktree('C:\\repo', worktreePath, false, { + knownRemovedWorktree: { branch: '', head: '', locked: false } + }) - expect(moveWorktreeDirectoryToTrashMock).not.toHaveBeenCalled() - expect(getGitCalls()).toEqual([`git worktree remove ${worktreePath}`]) - } finally { - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - } + expect(getGitCalls()).toEqual([`git -c core.longpaths=true worktree remove ${worktreePath}`]) }) it('passes one --force before the worktree path for dirty-file removal', async () => { @@ -574,10 +546,8 @@ branch refs/heads/feature/test 'git worktree remove failed' ) expect(getGitCalls()).not.toContain('git worktree remove --force /repo-feature') - // Only the pre-rename probe ran: an unrelated failure must not re-prove cleanliness. - expect( - getGitCalls().filter((call) => call === 'git status --porcelain --untracked-files=all') - ).toHaveLength(1) + // An unrelated failure must not re-prove cleanliness. + expect(getGitCalls()).not.toContain('git status --porcelain --untracked-files=all') }) it('rejects a locked worktree with stable app-owned copy before invoking remove', async () => { diff --git a/src/main/git/worktree-add-creation-config.test.ts b/src/main/git/worktree-add-creation-config.test.ts index ff44ca7ac6db..58b5202ed9b0 100644 --- a/src/main/git/worktree-add-creation-config.test.ts +++ b/src/main/git/worktree-add-creation-config.test.ts @@ -1,17 +1,13 @@ // addWorktree: checkout creation, branch-base/push.autoSetupRemote config writes, ref qualification. import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' -const { - gitExecFileAsyncMock, - gitExecFileSyncMock, - translateWslOutputPathsMock, - moveWorktreeDirectoryToTrashMock -} = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - gitExecFileSyncMock: vi.fn(), - translateWslOutputPathsMock: vi.fn((output: string) => output), - moveWorktreeDirectoryToTrashMock: vi.fn() -})) +const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted( + () => ({ + gitExecFileAsyncMock: vi.fn(), + gitExecFileSyncMock: vi.fn(), + translateWslOutputPathsMock: vi.fn((output: string) => output) + }) +) vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, @@ -19,13 +15,6 @@ vi.mock('./runner', () => ({ translateWslOutputPaths: translateWslOutputPathsMock })) -// Default: the checkout cannot be renamed aside, so removal deletes it in place. -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined), - restoreWorktreeDirectoryFromTrash: vi.fn().mockResolvedValue(true), - scheduleWorktreeTrashDeletion: vi.fn() -})) - import { addSparseWorktree, addWorktree, WORKTREE_ADD_TIMEOUT_MS } from './worktree' import { registerWorktreeSuiteHooks } from './worktree-test-harness' @@ -462,40 +451,6 @@ describe('addWorktree', () => { ]) }) - it('qualifies slash-containing local branch names when no remote ref matches', async () => { - gitExecFileAsyncMock.mockRejectedValueOnce(new Error('no remote ref')) // rev-parse refs/remotes/release/main^{commit} - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/heads/release/main^{commit} - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add - resolveCreationBaseConfigWrite() - gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote - - await addWorktree('/repo', '/repo-feature', 'feature/release', 'release/main') - - expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toEqual([ - ['rev-parse', '--verify', '--quiet', 'refs/remotes/release/main^{commit}'], - ['rev-parse', '--verify', '--quiet', 'refs/heads/release/main^{commit}'], - [ - 'worktree', - 'add', - '--no-track', - '-b', - 'feature/release', - '/repo-feature', - 'refs/heads/release/main' - ], - [ - 'config', - '--local', - '--replace-all', - 'branch.feature/release.base', - 'refs/heads/release/main' - ], - ['config', '--get', 'push.autoSetupRemote'], - ['config', '--local', 'push.autoSetupRemote', 'true'] - ]) - }) - it('does not report a local base refresh for slash-containing local branch names', async () => { gitExecFileAsyncMock.mockRejectedValueOnce(new Error('no remote ref')) // rev-parse refs/remotes/release/main^{commit} gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/heads/release/main^{commit} diff --git a/src/main/git/worktree-add-local-base-refresh.test.ts b/src/main/git/worktree-add-local-base-refresh.test.ts index 1ba2e6c16b88..e8ba08d98a05 100644 --- a/src/main/git/worktree-add-local-base-refresh.test.ts +++ b/src/main/git/worktree-add-local-base-refresh.test.ts @@ -1,16 +1,18 @@ -// addWorktree: fast-forwarding the local base ref (reset --hard / update-ref) and its safety bailouts. +// addWorktree: fast-forwarding the local base ref (merge --ff-only / update-ref) and its safety bailouts. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { gitExecFileAsyncMock, + refreshGitMock, + checkoutGitMock, gitExecFileSyncMock, - translateWslOutputPathsMock, - moveWorktreeDirectoryToTrashMock + translateWslOutputPathsMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), + refreshGitMock: vi.fn(), + checkoutGitMock: vi.fn(), gitExecFileSyncMock: vi.fn(), - translateWslOutputPathsMock: vi.fn((output: string) => output), - moveWorktreeDirectoryToTrashMock: vi.fn() + translateWslOutputPathsMock: vi.fn((output: string) => output) })) vi.mock('./runner', () => ({ @@ -19,13 +21,6 @@ vi.mock('./runner', () => ({ translateWslOutputPaths: translateWslOutputPathsMock })) -// Default: the checkout cannot be renamed aside, so removal deletes it in place. -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined), - restoreWorktreeDirectoryFromTrash: vi.fn().mockResolvedValue(true), - scheduleWorktreeTrashDeletion: vi.fn() -})) - import { addWorktree, WORKTREE_ADD_TIMEOUT_MS } from './worktree' import { registerWorktreeSuiteHooks } from './worktree-test-harness' @@ -33,53 +28,59 @@ registerWorktreeSuiteHooks() describe('addWorktree', () => { afterEach(() => vi.restoreAllMocks()) - const resolveCreationBaseConfigWrite = () => { - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base - } - beforeEach(() => { // These branch-safety assertions use POSIX argv; Windows flags have separate coverage. vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') - gitExecFileAsyncMock.mockReset() + // The refresh overlaps `worktree add`, so checkout calls get a fixed fake and the + // (sequential) base-resolution + refresh calls keep their own ordered queue. + refreshGitMock.mockReset() + checkoutGitMock.mockReset().mockImplementation(async (args: string[]) => { + if (args[0] === 'config' && args[1] === '--get') { + throw Object.assign(new Error('key unset'), { code: 1 }) + } + return { stdout: '' } + }) + gitExecFileAsyncMock + .mockReset() + .mockImplementation((args: string[], opts: unknown) => + (args[0] === 'worktree' && args[1] === 'add') || args[0] === 'config' + ? checkoutGitMock(args, opts) + : refreshGitMock(args, opts) + ) gitExecFileSyncMock.mockReset() translateWslOutputPathsMock.mockClear() }) - it('fast-forwards with reset --hard when localBranch is checked out in primary worktree', async () => { + it('fast-forwards with merge --ff-only when localBranch is checked out in primary worktree', async () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\nworktree /repo-other\nHEAD def456\nbranch refs/heads/feature\n' - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} - .mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count - .mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit} - .mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse remote tracking ref^{commit} - .mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs + queueBehindInspection() .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain .mockResolvedValueOnce({ stdout: '' }) // status --porcelain (in /repo) - .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list recheck - .mockResolvedValueOnce({ stdout: '' }) // status --porcelain recheck (in /repo) - .mockResolvedValueOnce({ stdout: '' }) // reset --hard (in /repo) - .mockResolvedValueOnce({ stdout: '' }) // worktree add - .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base - .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) - .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + .mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) // symbolic-ref -q HEAD (in /repo) + .mockResolvedValueOnce({ stdout: '' }) // merge --ff-only (in /repo) + .mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/heads/main after the merge - await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) + const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) - expect(gitExecFileAsyncMock.mock.calls).toEqual([ + expect(result.localBaseRefRefresh).toEqual({ + status: 'updated', + baseRef: 'origin/main', + localBranch: 'main', + ownerWorktreePath: '/repo' + }) + expect(refreshGitMock.mock.calls).toEqual([ [['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], { cwd: '/repo' }], - [ - ['rev-list', '--left-right', '--count', 'refs/heads/main...refs/remotes/origin/main'], - { cwd: '/repo' } - ], [['rev-parse', '--verify', 'refs/heads/main^{commit}'], { cwd: '/repo' }], [['rev-parse', '--verify', 'refs/remotes/origin/main^{commit}'], { cwd: '/repo' }], - [['merge-base', '--is-ancestor', 'old-main', 'remote-main'], { cwd: '/repo' }], - [['worktree', 'list', '--porcelain'], { cwd: '/repo' }], - [['status', '--porcelain', '--untracked-files=no'], { cwd: '/repo' }], + [['rev-list', '--left-right', '--count', 'old-main...remote-main'], { cwd: '/repo' }], [['worktree', 'list', '--porcelain'], { cwd: '/repo' }], - [['status', '--porcelain', '--untracked-files=no'], { cwd: '/repo' }], - [['reset', '--hard', 'remote-main'], { cwd: '/repo' }], + [['--no-optional-locks', 'status', '--porcelain', '--untracked-files=no'], { cwd: '/repo' }], + [['symbolic-ref', '-q', 'HEAD'], { cwd: '/repo' }], + [ownerFastForwardArgs('remote-main'), { cwd: '/repo' }], + [['rev-parse', '--verify', 'refs/heads/main^{commit}'], { cwd: '/repo' }] + ]) + expect(checkoutGitMock.mock.calls).toEqual([ [ [ 'worktree', @@ -107,55 +108,75 @@ describe('addWorktree', () => { ]) }) - it('fast-forwards with reset --hard in sibling worktree when localBranch is checked out there', async () => { + it('runs worktree add while the local base refresh is still fast-forwarding', async () => { + const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' + let finishMerge!: () => void + let markMergeStarted!: () => void + const mergeStarted = new Promise((resolve) => { + markMergeStarted = resolve + }) + queueBehindInspection() + .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain + .mockResolvedValueOnce({ stdout: '' }) // status --porcelain + .mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) // symbolic-ref -q HEAD + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishMerge = () => resolve({ stdout: '' }) + markMergeStarted() + }) + ) // merge --ff-only, held until worktree add is running + .mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/heads/main after the merge + // Would deadlock if the create awaited the refresh before starting the add. + checkoutGitMock.mockImplementationOnce(async () => { + await mergeStarted + finishMerge() + return { stdout: '' } + }) + + const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) + + expect(result.localBaseRefRefresh).toEqual({ + status: 'updated', + baseRef: 'origin/main', + localBranch: 'main', + ownerWorktreePath: '/repo' + }) + expect(checkoutGitMock.mock.calls[0]?.[0]).toContain('add') + }) + + it('fast-forwards in the sibling worktree when localBranch is checked out there', async () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n\nworktree /repo-main-wt\nHEAD def456\nbranch refs/heads/main\n' - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} - .mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count - .mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit} - .mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse remote tracking ref^{commit} - .mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs + queueBehindInspection() .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain .mockResolvedValueOnce({ stdout: '' }) // status --porcelain (in /repo-main-wt) - .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list recheck - .mockResolvedValueOnce({ stdout: '' }) // status --porcelain recheck (in /repo-main-wt) - .mockResolvedValueOnce({ stdout: '' }) // reset --hard (in /repo-main-wt) - .mockResolvedValueOnce({ stdout: '' }) // worktree add - .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base - .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) - .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + .mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) // symbolic-ref (in /repo-main-wt) + .mockResolvedValueOnce({ stdout: '' }) // merge --ff-only (in /repo-main-wt) + .mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/heads/main after the merge await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) - expect(gitExecFileAsyncMock.mock.calls[6]).toEqual([ - ['status', '--porcelain', '--untracked-files=no'], - expect.objectContaining({ cwd: '/repo-main-wt' }) - ]) - expect(gitExecFileAsyncMock.mock.calls[8]).toEqual([ - ['status', '--porcelain', '--untracked-files=no'], - expect.objectContaining({ cwd: '/repo-main-wt' }) - ]) - expect(gitExecFileAsyncMock.mock.calls[9]).toEqual([ - ['reset', '--hard', 'remote-main'], - expect.objectContaining({ cwd: '/repo-main-wt' }) + expect(refreshGitMock.mock.calls.slice(5)).toEqual([ + [ + ['--no-optional-locks', 'status', '--porcelain', '--untracked-files=no'], + expect.objectContaining({ cwd: '/repo-main-wt' }) + ], + [['symbolic-ref', '-q', 'HEAD'], expect.objectContaining({ cwd: '/repo-main-wt' })], + [ownerFastForwardArgs('remote-main'), expect.objectContaining({ cwd: '/repo-main-wt' })], + // Confirms local landed exactly on the target, read from the repo like the inspection. + [ + ['rev-parse', '--verify', 'refs/heads/main^{commit}'], + expect.objectContaining({ cwd: '/repo' }) + ] ]) }) it('fast-forwards local base via update-ref when localBranch is not checked out', async () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n' - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} - .mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count - .mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit} - .mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse remote tracking ref^{commit} - .mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs + queueBehindInspection() .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain .mockResolvedValueOnce({ stdout: '' }) // update-ref refs/heads/main remote-main old-main - .mockResolvedValueOnce({ stdout: '' }) // worktree add - .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base - .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) - .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) @@ -165,36 +186,36 @@ describe('addWorktree', () => { localBranch: 'main' }) // Compare-and-swap form (expected old OID) so a concurrent ref move is a no-op. - expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([ - 'update-ref', - 'refs/heads/main', - 'remote-main', - 'old-main' - ]) - // No worktree owns the branch, so no working tree is reset. - expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).not.toContainEqual([ - 'reset', - '--hard', - 'remote-main' + expect(refreshGitMock.mock.calls.at(-1)).toEqual([ + [ + 'update-ref', + '-m', + 'orca: fast-forward to refs/remotes/origin/main', + 'refs/heads/main', + 'remote-main', + 'old-main' + ], + { cwd: '/repo' } ]) + // No worktree owns the branch, so no working tree is touched. + expect(refreshGitMock.mock.calls.map(([args]) => args)).not.toContainEqual( + ownerFastForwardArgs('remote-main') + ) }) - it('skips local base refresh when the owner worktree becomes dirty before mutation', async () => { + it('reports the owner dirty when the fast-forward refuses to overwrite an edit made after inspection', async () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} - .mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count - .mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit} - .mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse remote tracking ref^{commit} - .mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs + queueBehindInspection() .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain - .mockResolvedValueOnce({ stdout: '' }) // status --porcelain during evaluation - .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list before mutation - .mockResolvedValueOnce({ stdout: ' M package.json\n' }) // status --porcelain before mutation - .mockResolvedValueOnce({ stdout: '' }) // worktree add - .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base - .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) - .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + .mockResolvedValueOnce({ stdout: '' }) // status --porcelain during inspection + .mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) // symbolic-ref -q HEAD + .mockRejectedValueOnce( + Object.assign(new Error('Command failed: git merge'), { + stderr: + 'error: Your local changes to the following files would be overwritten by merge:\n\tpackage.json' + }) + ) // merge --ff-only refuses + .mockRejectedValueOnce(Object.assign(new Error('not ancestor'), { code: 1 })) // merge-base const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) @@ -204,71 +225,38 @@ describe('addWorktree', () => { localBranch: 'main', ownerWorktreePath: '/repo' }) - expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).not.toContainEqual([ - 'update-ref', - 'refs/heads/main', - 'remote-main', - 'old-main' - ]) - expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).not.toContainEqual([ - 'reset', - '--hard', - 'refs/heads/main' + expect(refreshGitMock.mock.calls.at(-1)).toEqual([ + ['merge-base', '--is-ancestor', 'remote-main', 'refs/heads/main'], + expect.objectContaining({ cwd: '/repo' }) ]) + expect(gitExecFileAsyncMock.mock.calls.map(([args]) => args[0])).not.toContain('update-ref') + expect(gitExecFileAsyncMock.mock.calls.map(([args]) => args[0])).not.toContain('reset') }) it('skips local base refresh when the owner worktree switches branches before mutation', async () => { - const firstWorktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' - const secondWorktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n' - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} - .mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count - .mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit} - .mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/remotes/origin/main^{commit} - .mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs - .mockResolvedValueOnce({ stdout: firstWorktreeListOutput }) // worktree list during evaluation - .mockResolvedValueOnce({ stdout: '' }) // status --porcelain during evaluation - .mockResolvedValueOnce({ stdout: secondWorktreeListOutput }) // worktree list before mutation - .mockResolvedValueOnce({ stdout: '' }) // worktree add - .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base - .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) - .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' + queueBehindInspection() + .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list during inspection + .mockResolvedValueOnce({ stdout: '' }) // status --porcelain during inspection + .mockResolvedValueOnce({ stdout: 'refs/heads/develop\n' }) // symbolic-ref: owner switched + .mockRejectedValueOnce(Object.assign(new Error('not ancestor'), { code: 1 })) // merge-base const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) expect(result.localBaseRefRefresh).toEqual({ status: 'skipped_error', baseRef: 'origin/main', - localBranch: 'main' + localBranch: 'main', + ownerWorktreePath: '/repo' }) - expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).not.toContainEqual([ - 'update-ref', - 'refs/heads/main', - 'remote-main', - 'old-main' - ]) - expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).not.toContainEqual([ - 'reset', - '--hard', - 'refs/heads/main' - ]) + expect(gitExecFileAsyncMock.mock.calls.map(([args]) => args)).not.toContainEqual( + ownerFastForwardArgs('remote-main') + ) + expect(gitExecFileAsyncMock.mock.calls.map(([args]) => args[0])).not.toContain('update-ref') }) - it('skips local base refresh when owner revalidation cannot list worktrees', async () => { - const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} - .mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count - .mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit} - .mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/remotes/origin/main^{commit} - .mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs - .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list during evaluation - .mockResolvedValueOnce({ stdout: '' }) // status --porcelain during evaluation - .mockRejectedValueOnce(new Error('worktree list failed')) // worktree list before mutation - .mockResolvedValueOnce({ stdout: '' }) // worktree add - .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base - .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) - .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + it('skips local base refresh when worktree ownership cannot be listed', async () => { + queueBehindInspection().mockRejectedValueOnce(new Error('worktree list failed')) const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) @@ -277,28 +265,20 @@ describe('addWorktree', () => { baseRef: 'origin/main', localBranch: 'main' }) - expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).not.toContainEqual([ - 'update-ref', - 'refs/heads/main', - 'remote-main', - 'old-main' + expect(refreshGitMock.mock.calls.map(([args]) => args[0])).toEqual([ + 'rev-parse', + 'rev-parse', + 'rev-parse', + 'rev-list', + 'worktree' ]) }) it('skips update when the owning worktree is dirty', async () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} - .mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count - .mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit} - .mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse remote tracking ref^{commit} - .mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs + queueBehindInspection() .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain .mockResolvedValueOnce({ stdout: ' M package.json\n' }) // status --porcelain (dirty) - .mockResolvedValueOnce({ stdout: '' }) // worktree add - .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base - .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) - .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) @@ -309,52 +289,41 @@ describe('addWorktree', () => { ownerWorktreePath: '/repo' }) - // No reset --hard or update-ref — just base resolution, drift check, local/remote - // OIDs, ancestry check, worktree list, status, worktree add, and config writes. - expect(gitExecFileAsyncMock.mock.calls).toHaveLength(11) - expect(gitExecFileAsyncMock.mock.calls[0]?.[0]).toEqual([ + // No merge or update-ref: just base resolution, local/remote OIDs, drift count, worktree + // list and the owner status. + expect(refreshGitMock.mock.calls).toHaveLength(6) + expect(refreshGitMock.mock.calls[0]?.[0]).toEqual([ 'rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}' ]) - expect(gitExecFileAsyncMock.mock.calls[7]?.[0]).toEqual([ - 'worktree', - 'add', - '--no-track', - '-b', - 'feature/test', - '/repo-feature', - 'refs/remotes/origin/main' - ]) - expect(gitExecFileAsyncMock.mock.calls[8]?.[0]).toEqual([ - 'config', - '--local', - '--replace-all', - 'branch.feature/test.base', - 'refs/remotes/origin/main' - ]) - expect(gitExecFileAsyncMock.mock.calls[9]?.[0]).toEqual([ - 'config', - '--get', - 'push.autoSetupRemote' - ]) - expect(gitExecFileAsyncMock.mock.calls[10]?.[0]).toEqual([ - 'config', - '--local', - 'push.autoSetupRemote', - 'true' + expect(checkoutGitMock.mock.calls.map((call) => call[0])).toEqual([ + [ + 'worktree', + 'add', + '--no-track', + '-b', + 'feature/test', + '/repo-feature', + 'refs/remotes/origin/main' + ], + [ + 'config', + '--local', + '--replace-all', + 'branch.feature/test.base', + 'refs/remotes/origin/main' + ], + ['config', '--get', 'push.autoSetupRemote'], + ['config', '--local', 'push.autoSetupRemote', 'true'] ]) }) - it('skips updating the local branch when it has diverged', async () => { - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} - gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not a fast-forward')) - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // show-ref refs/heads/main (exists) - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add - resolveCreationBaseConfigWrite() - gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + it('skips updating the local branch when its drift probe fails but the branch exists', async () => { + queueBehindInspection({ counts: new Error('not a fast-forward') }).mockResolvedValueOnce({ + stdout: '' + }) // show-ref refs/heads/main (exists) const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) @@ -363,66 +332,47 @@ describe('addWorktree', () => { baseRef: 'origin/main', localBranch: 'main' }) - expect(gitExecFileAsyncMock.mock.calls).toEqual([ + expect(refreshGitMock.mock.calls).toEqual([ [ ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], expect.objectContaining({ cwd: '/repo' }) ], [ - ['rev-list', '--left-right', '--count', 'refs/heads/main...refs/remotes/origin/main'], + ['rev-parse', '--verify', 'refs/heads/main^{commit}'], expect.objectContaining({ cwd: '/repo' }) ], [ - ['show-ref', '--verify', '--quiet', '--', 'refs/heads/main'], + ['rev-parse', '--verify', 'refs/remotes/origin/main^{commit}'], expect.objectContaining({ cwd: '/repo' }) ], [ - [ - 'worktree', - 'add', - '--no-track', - '-b', - 'feature/test', - '/repo-feature', - 'refs/remotes/origin/main' - ], + ['rev-list', '--left-right', '--count', 'old-main...remote-main'], expect.objectContaining({ cwd: '/repo' }) ], [ - [ - 'config', - '--local', - '--replace-all', - 'branch.feature/test.base', - 'refs/remotes/origin/main' - ], - expect.objectContaining({ cwd: '/repo-feature' }) - ], - [ - ['config', '--get', 'push.autoSetupRemote'], - expect.objectContaining({ cwd: '/repo-feature' }) - ], - [ - ['config', '--local', 'push.autoSetupRemote', 'true'], - expect.objectContaining({ cwd: '/repo-feature' }) + ['show-ref', '--verify', '--quiet', '--', 'refs/heads/main'], + expect.objectContaining({ cwd: '/repo' }) ] ]) + expect(checkoutGitMock.mock.calls.map((call) => call[0][0])).toEqual([ + 'worktree', + 'config', + 'config', + 'config' + ]) }) - // #15331: evaluation runs before `-b ` exists, so rev-list fails on the missing local ref. + // #15331: `-b feature-x` proves there was no local feature-x to refresh. The add overlaps the + // refresh, so a probe could see the branch absent and then present (the add just wrote it). it('does not warn when worktree add itself creates the local base branch', async () => { - gitExecFileAsyncMock + refreshGitMock .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse --verify --quiet refs/remotes/origin/feature-x^{commit} .mockRejectedValueOnce( new Error( "fatal: ambiguous argument 'refs/heads/feature-x...refs/remotes/origin/feature-x': unknown revision or path not in the working tree." ) - ) // rev-list: refs/heads/feature-x does not exist yet - .mockRejectedValueOnce(Object.assign(new Error('missing ref'), { code: 1 })) // show-ref refs/heads/feature-x (missing) - .mockResolvedValueOnce({ stdout: '' }) // worktree add - .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base - .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) - .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + ) // rev-list, if it ran: refs/heads/feature-x not written yet + .mockResolvedValueOnce({ stdout: '' }) // show-ref, if it ran: the concurrent add has written it const result = await addWorktree( '/repo', @@ -442,26 +392,23 @@ describe('addWorktree', () => { '/repo-feature-x', 'refs/remotes/origin/feature-x' ]) - // Nothing was refreshed, so no ref mutation. + // No refresh probe raced the add, and nothing was mutated. + expect(refreshGitMock.mock.calls.map((call) => call[0][0])).toEqual(['rev-parse']) expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0][0])).not.toContain('update-ref') expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0][0])).not.toContain('reset') }) // #15331: same missing-local-branch class, but the new branch name differs from the base's. it('does not warn when the local base branch does not exist in a fetch-only clone', async () => { - gitExecFileAsyncMock + refreshGitMock .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse --verify --quiet refs/remotes/origin/main^{commit} - .mockRejectedValueOnce(new Error('unknown revision refs/heads/main')) // rev-list: no local main + .mockRejectedValueOnce(new Error('unknown revision refs/heads/main')) // rev-parse: no local main .mockRejectedValueOnce(Object.assign(new Error('missing ref'), { code: 1 })) // show-ref refs/heads/main (missing) - .mockResolvedValueOnce({ stdout: '' }) // worktree add - .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base - .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) - .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote const result = await addWorktree('/repo', '/repo-feature', 'my-feature', 'origin/main', true) expect(result.localBaseRefRefresh).toBeUndefined() - expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([ + expect(refreshGitMock.mock.calls.at(-1)?.[0]).toEqual([ 'show-ref', '--verify', '--quiet', @@ -472,14 +419,10 @@ describe('addWorktree', () => { // A failed probe is not proof of absence, so the warning must survive it. it('keeps the warning when the local base ref probe itself fails', async () => { - gitExecFileAsyncMock + refreshGitMock .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse --verify --quiet refs/remotes/origin/main^{commit} - .mockRejectedValueOnce(new Error('rev-list failed')) // drift probe + .mockRejectedValueOnce(new Error('rev-parse failed')) // local oid probe .mockRejectedValueOnce(new Error('fatal: not a git repository')) // show-ref probe could not run - .mockResolvedValueOnce({ stdout: '' }) // worktree add - .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base - .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) - .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote const result = await addWorktree('/repo', '/repo-feature', 'my-feature', 'origin/main', true) @@ -491,131 +434,103 @@ describe('addWorktree', () => { }) it('still suggests nothing but keeps the warning when the local base ref exists and diverged', async () => { - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse --verify --quiet refs/remotes/origin/main^{commit} - .mockResolvedValueOnce({ stdout: '2\t3\n' }) // rev-list: 2 local-only commits - .mockResolvedValueOnce({ stdout: '' }) // worktree add - .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base - .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) - .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + queueBehindInspection({ counts: { stdout: '2\t3\n' } }) // rev-list: 2 local-only commits - const result = await addWorktree('/repo', '/repo-feature', 'main', 'origin/main', true) + const result = await addWorktree('/repo', '/repo-feature', 'my-feature', 'origin/main', true) - // Same branch name as the base, but rev-list succeeded: real divergence must still warn. + // Local main exists with local-only commits: real divergence must still warn. expect(result.localBaseRefRefresh).toEqual({ status: 'skipped_not_fast_forward', baseRef: 'origin/main', localBranch: 'main' }) + expect(refreshGitMock.mock.calls).toHaveLength(4) }) - it('skips local base refresh when captured OIDs are no longer ancestor-safe', async () => { - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '0\t2\n' }) // stale rev-list result - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'new-local\n' }) // rev-parse refs/heads/main^{commit} - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/remotes/origin/main^{commit} - gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not an ancestor')) // merge-base captured OIDs - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // show-ref refs/heads/main (exists) - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add - resolveCreationBaseConfigWrite() - gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + it('reports not-fast-forward when local gained a commit after the inspection', async () => { + const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' + queueBehindInspection() + .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain + .mockResolvedValueOnce({ stdout: '' }) // status --porcelain + .mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) // symbolic-ref -q HEAD + .mockRejectedValueOnce( + Object.assign(new Error('Command failed: git merge'), { + stderr: 'fatal: Not possible to fast-forward, aborting.' + }) + ) // merge --ff-only refuses to drop the new commit + .mockRejectedValueOnce(Object.assign(new Error('not ancestor'), { code: 1 })) // merge-base const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true) expect(result.localBaseRefRefresh).toEqual({ status: 'skipped_not_fast_forward', baseRef: 'origin/main', - localBranch: 'main' + localBranch: 'main', + ownerWorktreePath: '/repo' }) - expect(gitExecFileAsyncMock.mock.calls).toEqual([ - [ - ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], - expect.objectContaining({ cwd: '/repo' }) - ], - [ - ['rev-list', '--left-right', '--count', 'refs/heads/main...refs/remotes/origin/main'], - expect.objectContaining({ cwd: '/repo' }) - ], - [ - ['rev-parse', '--verify', 'refs/heads/main^{commit}'], - expect.objectContaining({ cwd: '/repo' }) - ], - [ - ['rev-parse', '--verify', 'refs/remotes/origin/main^{commit}'], - expect.objectContaining({ cwd: '/repo' }) - ], - [ - ['merge-base', '--is-ancestor', 'new-local', 'remote-main'], - expect.objectContaining({ cwd: '/repo' }) - ], - [ - ['show-ref', '--verify', '--quiet', '--', 'refs/heads/main'], - expect.objectContaining({ cwd: '/repo' }) - ], - [ - [ - 'worktree', - 'add', - '--no-track', - '-b', - 'feature/test', - '/repo-feature', - 'refs/remotes/origin/main' - ], - expect.objectContaining({ cwd: '/repo' }) - ], - [ - [ - 'config', - '--local', - '--replace-all', - 'branch.feature/test.base', - 'refs/remotes/origin/main' - ], - expect.objectContaining({ cwd: '/repo-feature' }) - ], - [ - ['config', '--get', 'push.autoSetupRemote'], - expect.objectContaining({ cwd: '/repo-feature' }) - ], - [ - ['config', '--local', 'push.autoSetupRemote', 'true'], - expect.objectContaining({ cwd: '/repo-feature' }) - ] - ]) + expect(gitExecFileAsyncMock.mock.calls.map(([args]) => args[0])).not.toContain('reset') }) it('uses the remote name from the base ref instead of hardcoding origin', async () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/upstream/main^{commit} - .mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count - .mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit} - .mockResolvedValueOnce({ stdout: 'remote-upstream-main\n' }) // rev-parse refs/remotes/upstream/main^{commit} - .mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs + queueBehindInspection({ remoteOid: 'remote-upstream-main' }) .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain .mockResolvedValueOnce({ stdout: '' }) // status --porcelain - .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list recheck - .mockResolvedValueOnce({ stdout: '' }) // status --porcelain recheck - .mockResolvedValueOnce({ stdout: '' }) // reset --hard - .mockResolvedValueOnce({ stdout: '' }) // worktree add - .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base - .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset) - .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote + .mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) // symbolic-ref -q HEAD + .mockResolvedValueOnce({ stdout: '' }) // merge --ff-only + .mockResolvedValueOnce({ stdout: 'remote-upstream-main\n' }) // rev-parse refs/heads/main after the merge await addWorktree('/repo', '/repo-feature', 'feature/test', 'upstream/main', true) - expect(gitExecFileAsyncMock.mock.calls[1]?.[0]).toEqual([ + expect(refreshGitMock.mock.calls[2]?.[0]).toEqual([ + 'rev-parse', + '--verify', + 'refs/remotes/upstream/main^{commit}' + ]) + expect(refreshGitMock.mock.calls[3]?.[0]).toEqual([ 'rev-list', '--left-right', '--count', - 'refs/heads/main...refs/remotes/upstream/main' - ]) - expect(gitExecFileAsyncMock.mock.calls[9]?.[0]).toEqual([ - 'reset', - '--hard', - 'remote-upstream-main' + 'old-main...remote-upstream-main' ]) + expect(refreshGitMock.mock.calls[7]?.[0]).toEqual(ownerFastForwardArgs('remote-upstream-main')) }) }) + +function ownerFastForwardArgs(remoteOid: string): string[] { + return [ + '-c', + 'core.hooksPath=/dev/null', + '-c', + 'gc.auto=0', + '-c', + 'maintenance.auto=false', + '-c', + 'merge.autoStash=false', + '-c', + 'branch.main.mergeOptions=', + 'merge', + '--ff-only', + '-s', + 'recursive', + '--no-verify-signatures', + '--no-overwrite-ignore', + '--no-stat', + '-q', + remoteOid + ] +} + +/** Queues base resolution plus the inspection's local oid, remote oid and drift count. */ +function queueBehindInspection( + options: { remoteOid?: string; counts?: { stdout: string } | Error } = {} +) { + const counts = options.counts ?? { stdout: '0\t3\n' } + refreshGitMock + .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse --verify --quiet ^{commit} + .mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse --verify refs/heads/main^{commit} + .mockResolvedValueOnce({ stdout: `${options.remoteOid ?? 'remote-main'}\n` }) // rev-parse --verify ^{commit} + return counts instanceof Error + ? refreshGitMock.mockRejectedValueOnce(counts) + : refreshGitMock.mockResolvedValueOnce(counts) +} diff --git a/src/main/git/worktree-add-local-base-suggestion.test.ts b/src/main/git/worktree-add-local-base-suggestion.test.ts index 3b449c339f6d..3489a4d05b42 100644 --- a/src/main/git/worktree-add-local-base-suggestion.test.ts +++ b/src/main/git/worktree-add-local-base-suggestion.test.ts @@ -1,17 +1,13 @@ // addWorktree: advisory local-base-ref update suggestions when the refresh setting is off. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { - gitExecFileAsyncMock, - gitExecFileSyncMock, - translateWslOutputPathsMock, - moveWorktreeDirectoryToTrashMock -} = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - gitExecFileSyncMock: vi.fn(), - translateWslOutputPathsMock: vi.fn((output: string) => output), - moveWorktreeDirectoryToTrashMock: vi.fn() -})) +const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted( + () => ({ + gitExecFileAsyncMock: vi.fn(), + gitExecFileSyncMock: vi.fn(), + translateWslOutputPathsMock: vi.fn((output: string) => output) + }) +) vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, @@ -19,13 +15,6 @@ vi.mock('./runner', () => ({ translateWslOutputPaths: translateWslOutputPathsMock })) -// Default: the checkout cannot be renamed aside, so removal deletes it in place. -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined), - restoreWorktreeDirectoryFromTrash: vi.fn().mockResolvedValue(true), - scheduleWorktreeTrashDeletion: vi.fn() -})) - import { addWorktree } from './worktree' import { registerWorktreeSuiteHooks } from './worktree-test-harness' @@ -45,10 +34,9 @@ describe('addWorktree', () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} - .mockResolvedValueOnce({ stdout: '0\t2\n' }) // rev-list --left-right --count .mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit} .mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/remotes/origin/main^{commit} - .mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs + .mockResolvedValueOnce({ stdout: '0\t2\n' }) // rev-list --left-right --count .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain .mockResolvedValueOnce({ stdout: '' }) // status --porcelain .mockResolvedValueOnce({ stdout: '' }) // worktree add @@ -71,16 +59,23 @@ describe('addWorktree', () => { localBranch: 'main', behind: 2 }) - expect(gitExecFileAsyncMock.mock.calls[1]).toEqual([ - ['rev-list', '--left-right', '--count', 'refs/heads/main...refs/remotes/origin/main'], - { cwd: '/repo' } + expect(gitExecFileAsyncMock.mock.calls.slice(1, 6)).toEqual([ + [['rev-parse', '--verify', 'refs/heads/main^{commit}'], { cwd: '/repo' }], + [['rev-parse', '--verify', 'refs/remotes/origin/main^{commit}'], { cwd: '/repo' }], + [['rev-list', '--left-right', '--count', 'old-main...remote-main'], { cwd: '/repo' }], + [['worktree', 'list', '--porcelain'], { cwd: '/repo' }], + [['--no-optional-locks', 'status', '--porcelain', '--untracked-files=no'], { cwd: '/repo' }] ]) + // Advisory only: nothing moves the local branch. + const commands = gitExecFileAsyncMock.mock.calls.map(([args]) => args) + expect(commands.some((args) => args.includes('merge') || args[0] === 'update-ref')).toBe(false) }) it('skips advisory owner probes when the local base is already current', async () => { gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'abc123\n' }) // resolve creation base - .mockResolvedValueOnce({ stdout: '0\t0\n' }) // local base is current + .mockResolvedValueOnce({ stdout: 'same\n' }) // rev-parse refs/heads/main^{commit} + .mockResolvedValueOnce({ stdout: 'same\n' }) // rev-parse refs/remotes/origin/main^{commit}: current .mockResolvedValueOnce({ stdout: '' }) // worktree add .mockResolvedValueOnce({ stdout: '' }) // persist branch base .mockResolvedValueOnce({ stdout: 'true\n' }) // push.autoSetupRemote already set @@ -93,7 +88,8 @@ describe('addWorktree', () => { expect(gitExecFileAsyncMock.mock.calls.map(([args]) => args)).toEqual([ ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], - ['rev-list', '--left-right', '--count', 'refs/heads/main...refs/remotes/origin/main'], + ['rev-parse', '--verify', 'refs/heads/main^{commit}'], + ['rev-parse', '--verify', 'refs/remotes/origin/main^{commit}'], [ 'worktree', 'add', @@ -118,10 +114,9 @@ describe('addWorktree', () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/foo/bar/main^{commit} - .mockResolvedValueOnce({ stdout: '0\t2\n' }) // rev-list --left-right --count .mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit} .mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/remotes/foo/bar/main^{commit} - .mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs + .mockResolvedValueOnce({ stdout: '0\t2\n' }) // rev-list --left-right --count .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain .mockResolvedValueOnce({ stdout: '' }) // status --porcelain .mockResolvedValueOnce({ stdout: '' }) // worktree add @@ -151,8 +146,8 @@ describe('addWorktree', () => { localBranch: 'main', behind: 2 }) - expect(gitExecFileAsyncMock.mock.calls[1]).toEqual([ - ['rev-list', '--left-right', '--count', 'refs/heads/main...refs/remotes/foo/bar/main'], + expect(gitExecFileAsyncMock.mock.calls[2]).toEqual([ + ['rev-parse', '--verify', 'refs/remotes/foo/bar/main^{commit}'], { cwd: '/repo' } ]) }) @@ -161,10 +156,9 @@ describe('addWorktree', () => { const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} - .mockResolvedValueOnce({ stdout: '0\t2\n' }) // rev-list --left-right --count .mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit} - .mockResolvedValueOnce({ stdout: 'remote-upstream-main\n' }) // rev-parse refs/remotes/upstream/main^{commit} - .mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs + .mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/remotes/origin/main^{commit} + .mockResolvedValueOnce({ stdout: '0\t2\n' }) // rev-list --left-right --count .mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain .mockResolvedValueOnce({ stdout: ' M package.json\n' }) // status --porcelain .mockResolvedValueOnce({ stdout: '' }) // worktree add diff --git a/src/main/git/worktree-add-pending-removal.test.ts b/src/main/git/worktree-add-pending-removal.test.ts new file mode 100644 index 000000000000..fdb115979246 --- /dev/null +++ b/src/main/git/worktree-add-pending-removal.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted( + () => ({ + gitExecFileAsyncMock: vi.fn(), + gitExecFileSyncMock: vi.fn(), + translateWslOutputPathsMock: vi.fn((output: string) => output) + }) +) + +vi.mock('./runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + gitExecFileSync: gitExecFileSyncMock, + translateWslOutputPaths: translateWslOutputPathsMock +})) + +import { addWorktree } from './worktree' +import { registerWorktreeSuiteHooks } from './worktree-test-harness' +import { + _resetPendingWorktreeRemovalsForTests, + startBackgroundWorktreeRemoval +} from '../worktree-background-removal' + +registerWorktreeSuiteHooks() + +function startDeleting(): void { + void startBackgroundWorktreeRemoval({ + removal: { + worktreeId: 'repo-1::/repo-feature', + repoId: 'repo-1', + repoPath: '/repo', + worktree: { path: '/repo-feature', branch: 'refs/heads/feature/test', head: 'abc' }, + deleteBranch: true, + force: false + }, + run: () => new Promise(() => {}), + publish: () => {} + }) +} + +describe('addWorktree while Orca deletes a checkout in the background', () => { + afterEach(() => { + _resetPendingWorktreeRemovalsForTests() + }) + + it('refuses the same path with a clear message and runs no git', async () => { + startDeleting() + await expect(addWorktree('/repo', '/repo-feature', 'other-branch')).rejects.toThrow( + 'Orca is still deleting the workspace at /repo-feature. Cleanup is pending; try again shortly.' + ) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('refuses the same branch, which the delete removes when it finishes', async () => { + startDeleting() + await expect( + addWorktree('/repo', '/repo-elsewhere', 'feature/test', undefined, false, false, { + checkoutExistingBranch: true + }) + ).rejects.toThrow('Cleanup is pending; try again shortly.') + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/git/worktree-add-timeout-override.test.ts b/src/main/git/worktree-add-timeout-override.test.ts index 50aa4e59db12..eb67ba880f70 100644 --- a/src/main/git/worktree-add-timeout-override.test.ts +++ b/src/main/git/worktree-add-timeout-override.test.ts @@ -1,17 +1,13 @@ // resolveWorktreeAddTimeoutMs: ORCA_WORKTREE_ADD_TIMEOUT_MS parsing, clamping, and warnings. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { - gitExecFileAsyncMock, - gitExecFileSyncMock, - translateWslOutputPathsMock, - moveWorktreeDirectoryToTrashMock -} = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - gitExecFileSyncMock: vi.fn(), - translateWslOutputPathsMock: vi.fn((output: string) => output), - moveWorktreeDirectoryToTrashMock: vi.fn() -})) +const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted( + () => ({ + gitExecFileAsyncMock: vi.fn(), + gitExecFileSyncMock: vi.fn(), + translateWslOutputPathsMock: vi.fn((output: string) => output) + }) +) vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, @@ -19,13 +15,6 @@ vi.mock('./runner', () => ({ translateWslOutputPaths: translateWslOutputPathsMock })) -// Default: the checkout cannot be renamed aside, so removal deletes it in place. -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined), - restoreWorktreeDirectoryFromTrash: vi.fn().mockResolvedValue(true), - scheduleWorktreeTrashDeletion: vi.fn() -})) - import { resolveWorktreeAddTimeoutMs, WORKTREE_ADD_TIMEOUT_MS } from './worktree' import { registerWorktreeSuiteHooks } from './worktree-test-harness' diff --git a/src/main/git/worktree-add.ts b/src/main/git/worktree-add.ts index 380f3a3cc343..acce67288bad 100644 --- a/src/main/git/worktree-add.ts +++ b/src/main/git/worktree-add.ts @@ -10,6 +10,7 @@ import { runWithGitReadCacheInvalidation } from './status' import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing' import { getLocalBaseRefUpdateSuggestionForWorktreeCreate, + parseRemoteTrackingLocalBaseRef, refreshLocalBaseRefForWorktreeCreate } from './worktree-base-refresh' import { resolveWorktreeBaseCommitOid } from './worktree-base-ref-probe' @@ -20,32 +21,45 @@ import type { } from './worktree-operation-options' import { gitExecOptions, resolveWorktreeAddTimeoutMs } from './worktree-operation-options' import { bumpWorktreeScanGeneration } from './worktree-scan-cache' +import { assertNoPendingWorktreeRemovalConflict } from '../worktree-background-removal' -export type WorktreeAddBaseContext = AddWorktreeResult & { +export type WorktreeAddBaseContext = Pick & { effectiveBase: string effectiveBaseOid?: string + /** Started, not awaited: the worktree is created from the remote-tracking commit, so the refresh can overlap the checkout. Never rejects. */ + pendingLocalBaseRefRefresh?: Promise } export async function resolveWorktreeAddBaseContext( repoPath: string, baseBranch: string, refreshLocalBaseRef: boolean, - options: AddWorktreeOptions + options: AddWorktreeOptions, + createdBranch: string ): Promise { let effectiveBaseOid: string | null = null const effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, async (qualifiedRef) => { effectiveBaseOid = await resolveWorktreeBaseCommitOid(repoPath, qualifiedRef, options) return effectiveBaseOid !== null }) - const localBaseRefRefresh = refreshLocalBaseRef - ? await refreshLocalBaseRefForWorktreeCreate( - repoPath, - baseBranch, - effectiveBase, - options.remoteTrackingBase, - options - ) - : undefined + // Why: `-b` refuses an existing branch, so creating the base's own local branch leaves nothing to refresh; probing it would race the overlapped add's branch write into a false "not fast-forward" warning. + const createsLocalBaseBranch = + parseRemoteTrackingLocalBaseRef(baseBranch, effectiveBase, options.remoteTrackingBase) + ?.localBranch === createdBranch + const pendingLocalBaseRefRefresh = + refreshLocalBaseRef && !createsLocalBaseBranch + ? refreshLocalBaseRefForWorktreeCreate( + repoPath, + baseBranch, + effectiveBase, + options.remoteTrackingBase, + options + ).catch((error: unknown) => { + // Why: the create may already have succeeded by the time this settles; a refresh bug must not fail it. + console.warn('addWorktree: local base ref refresh failed unexpectedly', error) + return undefined + }) + : undefined const localBaseRefUpdateSuggestion = !refreshLocalBaseRef && options.suggestLocalBaseRefUpdate ? await getLocalBaseRefUpdateSuggestionForWorktreeCreate( @@ -62,7 +76,7 @@ export async function resolveWorktreeAddBaseContext( ...(!refreshLocalBaseRef && !options.suggestLocalBaseRefUpdate && effectiveBaseOid ? { effectiveBaseOid } : {}), - ...(localBaseRefRefresh ? { localBaseRefRefresh } : {}), + ...(pendingLocalBaseRefRefresh ? { pendingLocalBaseRefRefresh } : {}), ...(localBaseRefUpdateSuggestion ? { localBaseRefUpdateSuggestion } : {}) } } @@ -184,7 +198,10 @@ async function performAddWorktree( noCheckout = false, options: AddWorktreeOptions = {} ): Promise { - let localBaseRefRefresh: LocalBaseRefRefreshResult | undefined + // Why: Git still owns that path and branch until the background delete finishes; a create now + // would race it, and the branch cleanup that follows would find the branch checked out again. + assertNoPendingWorktreeRemovalConflict(repoPath, { worktreePath, branch }) + let pendingLocalBaseRefRefresh: Promise | undefined let localBaseRefUpdateSuggestion: LocalBaseRefUpdateSuggestion | undefined // Why: enable long paths for this Windows checkout without changing user Git config. const args = [...windowsLongPathGitArgs(repoPath), 'worktree', 'add'] @@ -203,10 +220,11 @@ async function performAddWorktree( repoPath, baseBranch, refreshLocalBaseRef, - options + options, + branch ) effectiveBase = baseContext.effectiveBase - localBaseRefRefresh = baseContext.localBaseRefRefresh + pendingLocalBaseRefRefresh = baseContext.pendingLocalBaseRefRefresh localBaseRefUpdateSuggestion = baseContext.localBaseRefUpdateSuggestion args.push(effectiveBase) } @@ -217,6 +235,10 @@ async function performAddWorktree( // Why: resolve per call — hoisting this to a module const would freeze the override at import. timeout: resolveWorktreeAddTimeoutMs() }) + } catch (error) { + // Why: settle the overlapped refresh inside the caller's ref-maintenance pause before reporting the failure. + await pendingLocalBaseRefRefresh + throw error } finally { // Git may have written the target's `.git` marker even when it reports a late // failure, so drop any pre-create route before the follow-up commands route. @@ -224,7 +246,7 @@ async function performAddWorktree( } if (options.checkoutExistingBranch) { - return localBaseRefRefresh ? { localBaseRefRefresh } : {} + return {} } if (effectiveBase) { @@ -237,6 +259,7 @@ async function performAddWorktree( // linked worktree writes the shared common-dir config (whole repo) — intentional and idempotent, // so it's warn-only and not rolled back on failure. await configurePushAutoSetupRemote(worktreePath, options) + const localBaseRefRefresh = await pendingLocalBaseRefRefresh return { ...(localBaseRefRefresh ? { localBaseRefRefresh } : {}), ...(localBaseRefUpdateSuggestion ? { localBaseRefUpdateSuggestion } : {}) diff --git a/src/main/git/worktree-base-refresh-analysis.ts b/src/main/git/worktree-base-refresh-analysis.ts deleted file mode 100644 index f79f6f820964..000000000000 --- a/src/main/git/worktree-base-refresh-analysis.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output' -import type { - LocalBaseRefRefreshResult, - LocalBaseRefUpdateSuggestion -} from '../../shared/worktree/base-ref-drift-types' -import { gitExecFileAsync, translateWslOutputPaths } from './runner' -import { probeWorktreeBaseRefPresence } from './worktree-base-ref-probe' -import { parseWorktreeList } from '../../shared/git-worktree-porcelain-parser' -import type { AddWorktreeOptions, GitWorktreeExecOptions } from './worktree-operation-options' -import { gitExecOptions } from './worktree-operation-options' - -type LocalBaseRefRefreshability = - | { - refreshable: true - baseRef: string - localBranch: string - fullRef: string - remoteTrackingRef: string - localOid: string - remoteOid: string - behind: number - ownerWorktreePath?: string - } - | { - refreshable: false - result: LocalBaseRefRefreshResult - } - -function parseRemoteTrackingLocalBaseRef( - baseBranch: string, - remoteTrackingRef: string, - remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'] -): { baseRef: string; localBranch: string; fullRef: string } | undefined { - if (remoteTrackingBase?.ref === remoteTrackingRef) { - return { - baseRef: remoteTrackingBase.base, - localBranch: remoteTrackingBase.branch, - fullRef: `refs/heads/${remoteTrackingBase.branch}` - } - } - - const remoteRefPrefix = 'refs/remotes/' - if (!remoteTrackingRef.startsWith(remoteRefPrefix)) { - return undefined - } - - // Why: only proven remote-tracking refs get refresh status; slash-containing local branches (release/2026) must not fake a "not refreshed" warning. - const shortRemoteRef = remoteTrackingRef.slice(remoteRefPrefix.length) - const slashIndex = shortRemoteRef.indexOf('/') - if (slashIndex <= 0) { - return undefined - } - - const localBranch = shortRemoteRef.slice(slashIndex + 1) - return { - baseRef: baseBranch, - localBranch, - fullRef: `refs/heads/${localBranch}` - } -} - -function parseRevListDrift(output: string): { ahead: number; behind: number } | null { - const counts = parseGitRevListAheadBehindCounts(output) - return counts.status === 'ok' ? { ahead: counts.ahead, behind: counts.behind } : null -} - -export async function evaluateLocalBaseRefRefreshability( - repoPath: string, - baseBranch: string, - remoteTrackingRef: string, - remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'], - options: GitWorktreeExecOptions = {}, - shouldInspectOwner: (behind: number) => boolean = () => true -): Promise { - const parsed = parseRemoteTrackingLocalBaseRef(baseBranch, remoteTrackingRef, remoteTrackingBase) - if (!parsed) { - return undefined - } - - const resultBase = { baseRef: parsed.baseRef, localBranch: parsed.localBranch } - - let drift: { ahead: number; behind: number } - let localOid = '' - let remoteOid = '' - try { - // Why: advisory and mutating paths must agree on "safe to fast-forward"; `rev-list A...B` proves no local-only commits and how far behind. - const { stdout } = await gitExecFileAsync( - ['rev-list', '--left-right', '--count', `${parsed.fullRef}...${remoteTrackingRef}`], - gitExecOptions(repoPath, options) - ) - const parsedDrift = parseRevListDrift(stdout) - if (!parsedDrift || parsedDrift.ahead !== 0) { - return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } } - } - if (!shouldInspectOwner(parsedDrift.behind)) { - // Why: a current local ref yields no update suggestion, so the advisory path skips OID resolution and owner inspection. - return undefined - } - const { stdout: localOidOutput } = await gitExecFileAsync( - ['rev-parse', '--verify', `${parsed.fullRef}^{commit}`], - gitExecOptions(repoPath, options) - ) - localOid = localOidOutput.trim() - if (!localOid) { - return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } } - } - const { stdout: remoteOidOutput } = await gitExecFileAsync( - ['rev-parse', '--verify', `${remoteTrackingRef}^{commit}`], - gitExecOptions(repoPath, options) - ) - remoteOid = remoteOidOutput.trim() - if (!remoteOid) { - return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } } - } - await gitExecFileAsync( - ['merge-base', '--is-ancestor', localOid, remoteOid], - gitExecOptions(repoPath, options) - ) - drift = parsedDrift - } catch { - // Why (#15331): the probes above also fail when refs/heads/ is simply absent; a branch that - // does not exist yet cannot be stale, so report nothing instead of a bogus divergence warning. - // Only a proven absence suppresses: an unusable repo leaves the warning alone. - const presence = await probeWorktreeBaseRefPresence( - (args) => gitExecFileAsync(args, gitExecOptions(repoPath, options)), - parsed.fullRef - ) - if (presence === 'absent') { - return undefined - } - return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } } - } - - try { - // Why: if the local base branch is checked out, only update it when that owner worktree is clean. - const { stdout: worktreeListOutput } = await gitExecFileAsync( - ['worktree', 'list', '--porcelain'], - gitExecOptions(repoPath, options) - ) - const worktrees = parseWorktreeList( - translateWslOutputPaths(worktreeListOutput, repoPath, options) - ) - const ownerWorktree = worktrees.find((wt) => wt.branch === parsed.fullRef) - - if (ownerWorktree) { - const { stdout: status } = await gitExecFileAsync( - ['status', '--porcelain', '--untracked-files=no'], - gitExecOptions(ownerWorktree.path, options) - ) - if (status.trim()) { - return { - refreshable: false, - result: { - ...resultBase, - status: 'skipped_dirty_worktree', - ownerWorktreePath: ownerWorktree.path - } - } - } - return { - refreshable: true, - ...resultBase, - fullRef: parsed.fullRef, - remoteTrackingRef, - localOid, - remoteOid, - behind: drift.behind, - ownerWorktreePath: ownerWorktree.path - } - } - - // Why: localBranch isn't checked out anywhere, so a bare-ref fast-forward is safe; omitting ownerWorktreePath signals the mutating path to take it. - return { - refreshable: true, - ...resultBase, - fullRef: parsed.fullRef, - remoteTrackingRef, - localOid, - remoteOid, - behind: drift.behind - } - } catch { - return { refreshable: false, result: { ...resultBase, status: 'skipped_error' } } - } -} - -export async function getLocalBaseRefUpdateSuggestionForWorktreeCreate( - repoPath: string, - baseBranch: string, - remoteTrackingRef: string, - remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'], - options: GitWorktreeExecOptions = {} -): Promise { - const evaluation = await evaluateLocalBaseRefRefreshability( - repoPath, - baseBranch, - remoteTrackingRef, - remoteTrackingBase, - options, - (behind) => behind > 0 - ) - if (!evaluation?.refreshable || evaluation.behind <= 0) { - return undefined - } - return { - baseRef: evaluation.baseRef, - localBranch: evaluation.localBranch, - behind: evaluation.behind - } -} diff --git a/src/main/git/worktree-base-refresh-contention.test.ts b/src/main/git/worktree-base-refresh-contention.test.ts new file mode 100644 index 000000000000..b71e13ba0816 --- /dev/null +++ b/src/main/git/worktree-base-refresh-contention.test.ts @@ -0,0 +1,322 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: + vi.fn<(args: string[], opts: { cwd: string }) => Promise<{ stdout: string }>>() +})) + +vi.mock('./runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + translateWslOutputPaths: (output: string) => output +})) + +import { + LOCAL_BASE_REF_REFRESH_WAIT_MS, + refreshLocalBaseRefForWorktreeCreate +} from './worktree-base-refresh' + +const INDEX_LOCK_ERROR = Object.assign(new Error('Command failed: git merge --ff-only'), { + stderr: + "fatal: Unable to create '/repo/.git/index.lock': File exists.\n\nAnother git process seems to be running in this repository" +}) +const OWNER_WORKTREE_LIST = 'worktree /repo\nHEAD old-main\nbranch refs/heads/main\n' + +type GitFake = { + owner?: boolean + mutate: () => Promise<{ stdout: string }> + /** Whether local already contains the target when a failed move is re-checked. */ + localContainsTarget?: () => boolean + ownerHead?: () => string +} + +/** The git subcommand, past leading `-c key=value` and `--flag` globals. */ +function commandOf(args: readonly string[]): string { + for (let index = 0; index < args.length; index += 1) { + if (args[index] === '-c') { + index += 1 + } else if (!args[index].startsWith('-')) { + return args[index] + } + } + return '' +} + +function installGitFake(fake: GitFake): void { + let localMain = 'old-main' + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const command = commandOf(args) + if (command === 'rev-parse') { + return { stdout: args[2] === 'refs/heads/main^{commit}' ? `${localMain}\n` : 'remote-main\n' } + } + if (command === 'rev-list') { + return { stdout: '0\t3\n' } + } + if (command === 'status') { + return { stdout: '' } + } + if (command === 'worktree') { + return { stdout: fake.owner ? OWNER_WORKTREE_LIST : '' } + } + if (command === 'symbolic-ref') { + return { stdout: `${fake.ownerHead?.() ?? 'refs/heads/main'}\n` } + } + if (command === 'merge-base') { + if (fake.localContainsTarget?.()) { + return { stdout: '' } + } + throw Object.assign(new Error('not an ancestor'), { code: 1 }) + } + if (command === 'merge' || command === 'update-ref') { + const result = await fake.mutate() + localMain = 'remote-main' + return result + } + throw new Error(`unexpected git ${args.join(' ')}`) + }) +} + +function refresh(repoPath = '/repo') { + return refreshLocalBaseRefForWorktreeCreate(repoPath, 'origin/main', 'refs/remotes/origin/main') +} + +function mutationCalls(): string[][] { + return gitExecFileAsyncMock.mock.calls + .map(([args]) => args) + .filter((args) => ['merge', 'update-ref', 'reset'].includes(commandOf(args))) +} + +describe('refreshLocalBaseRefForWorktreeCreate lock contention', () => { + beforeEach(() => { + vi.useFakeTimers() + gitExecFileAsyncMock.mockReset() + }) + afterEach(() => vi.useRealTimers()) + + it('retries a fast-forward that lost the index.lock race and reports updated', async () => { + const mutate = vi + .fn() + .mockRejectedValueOnce(INDEX_LOCK_ERROR) + .mockResolvedValueOnce({ stdout: '' }) + installGitFake({ owner: true, mutate }) + + const pending = refresh() + await vi.runAllTimersAsync() + + await expect(pending).resolves.toEqual({ + baseRef: 'origin/main', + localBranch: 'main', + status: 'updated', + ownerWorktreePath: '/repo' + }) + expect(mutationCalls().map(commandOf)).toEqual(['merge', 'merge']) + expect(mutationCalls().map((args) => args.at(-1))).toEqual(['remote-main', 'remote-main']) + }) + + it('re-confirms the owner has the branch checked out before each retry', async () => { + let symbolicRefReads = 0 + installGitFake({ + owner: true, + mutate: () => Promise.reject(INDEX_LOCK_ERROR), + // The user switches the owner checkout to another branch while the first attempt waits. + ownerHead: () => (++symbolicRefReads >= 2 ? 'refs/heads/develop' : 'refs/heads/main') + }) + + const pending = refresh() + await vi.runAllTimersAsync() + + await expect(pending).resolves.toMatchObject({ status: 'skipped_error' }) + expect(mutationCalls()).toHaveLength(1) + }) + + it('reports updated without a warning when the lock never clears but local already reached the target', async () => { + installGitFake({ + owner: true, + mutate: () => Promise.reject(INDEX_LOCK_ERROR), + // The concurrent lock holder fast-forwarded local itself. + localContainsTarget: () => true + }) + + const pending = refresh() + await vi.runAllTimersAsync() + + await expect(pending).resolves.toMatchObject({ status: 'updated' }) + expect(mutationCalls()).toHaveLength(4) + }) + + it('reports skipped_error when the lock never clears and local is still behind', async () => { + installGitFake({ owner: true, mutate: () => Promise.reject(INDEX_LOCK_ERROR) }) + + const pending = refresh() + await vi.runAllTimersAsync() + + await expect(pending).resolves.toMatchObject({ status: 'skipped_error' }) + expect(mutationCalls()).toHaveLength(4) + }) + + it('does not retry a failure that is not lock contention', async () => { + const casMismatch = Object.assign(new Error('Command failed: git update-ref'), { + stderr: "fatal: cannot lock ref 'refs/heads/main': is at other-oid but expected old-main" + }) + installGitFake({ mutate: () => Promise.reject(casMismatch) }) + + const pending = refresh() + await vi.runAllTimersAsync() + + await expect(pending).resolves.toMatchObject({ status: 'skipped_error' }) + expect(mutationCalls()).toEqual([ + [ + 'update-ref', + '-m', + 'orca: fast-forward to refs/remotes/origin/main', + 'refs/heads/main', + 'remote-main', + 'old-main' + ] + ]) + }) + + it('treats a lost update-ref race as success when someone else moved local to the target', async () => { + const casMismatch = Object.assign(new Error('Command failed: git update-ref'), { + stderr: "fatal: cannot lock ref 'refs/heads/main': is at remote-main but expected old-main" + }) + installGitFake({ mutate: () => Promise.reject(casMismatch), localContainsTarget: () => true }) + + await expect(refresh()).resolves.toEqual({ + baseRef: 'origin/main', + localBranch: 'main', + status: 'updated' + }) + expect(mutationCalls()).toHaveLength(1) + }) + + it('skips the owner check and the move when local is already current', async () => { + installGitFake({ owner: true, mutate: () => Promise.resolve({ stdout: '' }) }) + const base = gitExecFileAsyncMock.getMockImplementation()! + gitExecFileAsyncMock.mockImplementation(async (args: string[], opts: { cwd: string }) => + args[0] === 'rev-parse' ? { stdout: 'remote-main\n' } : base(args, opts) + ) + + await expect(refresh()).resolves.toBeUndefined() + expect(gitExecFileAsyncMock.mock.calls.map(([args]) => args[0])).toEqual([ + 'rev-parse', + 'rev-parse' + ]) + }) + + it("runs the shared refresh without one create's abort signal or timeout", async () => { + installGitFake({ owner: true, mutate: () => Promise.resolve({ stdout: '' }) }) + + await refreshLocalBaseRefForWorktreeCreate( + '/repo', + 'origin/main', + 'refs/remotes/origin/main', + undefined, + { wslDistro: 'Ubuntu', signal: new AbortController().signal, timeout: 8000 } + ) + + for (const [, options] of gitExecFileAsyncMock.mock.calls) { + expect(options).toEqual({ cwd: expect.any(String), wslDistro: 'Ubuntu' }) + } + }) +}) + +describe('refreshLocalBaseRefForWorktreeCreate runs one refresh at a time per branch', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + }) + afterEach(() => vi.useRealTimers()) + + // Local moves to the target once the (held) owner fast-forward finishes. + function installRepoFake(repoPath: string) { + let localOid = 'old-main' + let finishMerge!: () => void + const mergeStarted = new Promise((resolve) => { + gitExecFileAsyncMock.mockImplementation(async (args: string[], opts: { cwd: string }) => { + const command = commandOf(args) + if (opts.cwd.replace(/\/+$/, '') !== repoPath) { + return { stdout: command === 'rev-parse' ? 'current\n' : '' } + } + if (command === 'rev-parse') { + return { stdout: args[2]?.startsWith('refs/heads/') ? `${localOid}\n` : 'remote-main\n' } + } + if (command === 'rev-list') { + return { stdout: '0\t3\n' } + } + if (command === 'worktree') { + return { stdout: `worktree ${repoPath}\nHEAD ${localOid}\nbranch refs/heads/main\n` } + } + if (command === 'symbolic-ref') { + return { stdout: 'refs/heads/main\n' } + } + if (command === 'merge') { + resolve() + await new Promise((release) => { + finishMerge = release + }) + localOid = 'remote-main' + } + return { stdout: '' } + }) + }) + return { mergeStarted, finishMerge: () => finishMerge() } + } + + it('folds creates that arrive mid-refresh into one follow-up that finds local current', async () => { + const repo = installRepoFake('/repo') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const first = refresh('/repo') + const joiners = [refresh('/repo/'), refresh('/repo')] + await repo.mergeStarted + const callsWhileFirstMerges = gitExecFileAsyncMock.mock.calls.length + repo.finishMerge() + + await expect(first).resolves.toMatchObject({ status: 'updated' }) + await expect(Promise.all(joiners)).resolves.toEqual([undefined, undefined]) + const commands = gitExecFileAsyncMock.mock.calls.map(([args]) => commandOf(args)) + // Nothing ran for the joiners while the first held the checkout; after its post-move check, one inspection for all. + expect(commands.slice(0, callsWhileFirstMerges).filter((c) => c === 'rev-list')).toHaveLength(1) + expect(commands.slice(callsWhileFirstMerges)).toEqual(['rev-parse', 'rev-parse', 'rev-parse']) + expect(mutationCalls()).toHaveLength(1) + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + }) + + it('does not queue refreshes of different repos behind each other', async () => { + const repo = installRepoFake('/repo') + + const first = refresh('/repo') + await repo.mergeStarted + await expect(refresh('/other')).resolves.toBeUndefined() + repo.finishMerge() + await expect(first).resolves.toMatchObject({ status: 'updated' }) + }) + + it('stops waiting on a wedged refresh after the bound without starting a second move', async () => { + vi.useFakeTimers() + const repo = installRepoFake('/repo') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + let secondResult: unknown = 'pending' + + const first = refresh('/repo') + await repo.mergeStarted + await vi.advanceTimersByTimeAsync(10_000) + const second = refresh('/repo').then((result) => (secondResult = result)) + + await vi.advanceTimersByTimeAsync(LOCAL_BASE_REF_REFRESH_WAIT_MS - 1) + expect(secondResult).toBe('pending') + await vi.advanceTimersByTimeAsync(1) + await second + expect(secondResult).toBeUndefined() + await expect(first).resolves.toBeUndefined() + expect(mutationCalls()).toHaveLength(1) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('stopped waiting')) + + // Settle the wedged run so the follow-up finds local current and frees the slot. + repo.finishMerge() + await vi.runAllTimersAsync() + await vi.waitFor(() => expect(gitExecFileAsyncMock.mock.calls.at(-1)?.[0][0]).toBe('rev-parse')) + expect(mutationCalls()).toHaveLength(1) + warn.mockRestore() + }) +}) diff --git a/src/main/git/worktree-base-refresh-lock-real-git.test.ts b/src/main/git/worktree-base-refresh-lock-real-git.test.ts new file mode 100644 index 000000000000..aaf292810b47 --- /dev/null +++ b/src/main/git/worktree-base-refresh-lock-real-git.test.ts @@ -0,0 +1,212 @@ +import { execFileSync } from 'node:child_process' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as gitRunner from './runner' +import { refreshLocalBaseRefForWorktreeCreate } from './worktree-base-refresh' + +const tempRoots: string[] = [] + +function git(cwd: string, args: string[]): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }).trim() +} + +// Primary checkout on `main` one commit behind `refs/remotes/origin/main`, with its index locked. +async function createBehindRepoWithIndexLock(): Promise<{ + repoPath: string + lockPath: string + localOid: string + remoteOid: string +}> { + const root = await mkdtemp(join(tmpdir(), 'orca-base-refresh-lock-')) + tempRoots.push(root) + const repoPath = join(root, 'repo') + execFileSync('git', ['init', '--quiet', repoPath]) + git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main']) + git(repoPath, ['config', 'user.email', 'test@example.com']) + git(repoPath, ['config', 'user.name', 'Test User']) + git(repoPath, ['config', 'core.autocrlf', 'false']) + await writeFile(join(repoPath, 'version.txt'), 'one\n') + git(repoPath, ['add', 'version.txt']) + git(repoPath, ['commit', '--quiet', '-m', 'one']) + const localOid = git(repoPath, ['rev-parse', 'HEAD']) + git(repoPath, ['checkout', '--quiet', '-b', 'upstream']) + await writeFile(join(repoPath, 'version.txt'), 'two\n') + git(repoPath, ['commit', '--quiet', '-am', 'two']) + const remoteOid = git(repoPath, ['rev-parse', 'HEAD']) + git(repoPath, ['checkout', '--quiet', 'main']) + git(repoPath, ['update-ref', 'refs/remotes/origin/main', remoteOid]) + git(repoPath, ['branch', '--quiet', '-D', 'upstream']) + const lockPath = join(repoPath, '.git', 'index.lock') + await writeFile(lockPath, '') + return { repoPath, lockPath, localOid, remoteOid } +} + +function isOwnerFastForward(args: readonly string[]): boolean { + return args.includes('merge') && args.includes('--ff-only') +} + +/** Counts owner fast-forwards; `onFailure` runs after each failed one, before it is reported. */ +function spyOnFastForwards(onFailure: () => Promise | void = () => {}): { + merges: () => number + maxConcurrent: () => number +} { + const original = gitRunner.gitExecFileAsync + let merges = 0 + let active = 0 + let maxConcurrent = 0 + vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args, options) => { + if (!isOwnerFastForward(args)) { + return original(args, options) + } + merges += 1 + active += 1 + maxConcurrent = Math.max(maxConcurrent, active) + try { + return await original(args, options) + } catch (error) { + await onFailure() + throw error + } finally { + active -= 1 + } + }) + return { merges: () => merges, maxConcurrent: () => maxConcurrent } +} + +function refresh(repoPath: string, remote: 'origin' | 'upstream' = 'origin') { + return refreshLocalBaseRefForWorktreeCreate( + repoPath, + `${remote}/main`, + `refs/remotes/${remote}/main` + ) +} + +// A fork: checked-out `main` behind `origin/main`, which is one commit behind `upstream/main`. +async function createForkRepo(): Promise<{ + repoPath: string + originOid: string + upstreamOid: string +}> { + const { repoPath, lockPath, remoteOid: originOid } = await createBehindRepoWithIndexLock() + await rm(lockPath, { force: true }) + git(repoPath, ['checkout', '--quiet', '-b', 'fork-upstream', originOid]) + await writeFile(join(repoPath, 'version.txt'), 'three\n') + git(repoPath, ['commit', '--quiet', '-am', 'three']) + const upstreamOid = git(repoPath, ['rev-parse', 'HEAD']) + git(repoPath, ['checkout', '--quiet', 'main']) + git(repoPath, ['update-ref', 'refs/remotes/upstream/main', upstreamOid]) + git(repoPath, ['branch', '--quiet', '-D', 'fork-upstream']) + return { repoPath, originOid, upstreamOid } +} + +beforeEach(() => { + // Why: a developer's global config (e.g. merge.verifySignatures) must not change what these repos do. + vi.stubEnv('GIT_CONFIG_NOSYSTEM', '1') + vi.stubEnv('GIT_CONFIG_GLOBAL', join(tmpdir(), `orca-no-global-gitconfig-${process.pid}`)) +}) + +afterEach(async () => { + vi.unstubAllEnvs() + vi.restoreAllMocks() + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('local base refresh against a held index.lock with real Git', () => { + it('retries once the other git process releases the lock', async () => { + const { repoPath, lockPath, remoteOid } = await createBehindRepoWithIndexLock() + const spy = spyOnFastForwards(() => rm(lockPath, { force: true })) + + const result = await refresh(repoPath) + + expect(result).toMatchObject({ status: 'updated' }) + expect(spy.merges()).toBe(2) + expect(git(repoPath, ['rev-parse', 'main'])).toBe(remoteOid) + expect(await readFile(join(repoPath, 'version.txt'), 'utf8')).toBe('two\n') + }) + + it('reports updated when another process fast-forwarded local while holding the lock', async () => { + const { repoPath, remoteOid } = await createBehindRepoWithIndexLock() + spyOnFastForwards(() => { + git(repoPath, ['update-ref', 'refs/heads/main', remoteOid]) + }) + + const result = await refresh(repoPath) + + expect(result).toMatchObject({ status: 'updated' }) + expect(git(repoPath, ['rev-parse', 'main'])).toBe(remoteOid) + }) + + it('reports skipped_error when the lock outlives every retry', async () => { + const { repoPath, localOid } = await createBehindRepoWithIndexLock() + const spy = spyOnFastForwards() + + const result = await refresh(repoPath) + + expect(result).toMatchObject({ status: 'skipped_error' }) + expect(spy.merges()).toBe(4) + expect(git(repoPath, ['rev-parse', 'main'])).toBe(localOid) + }) +}) + +describe('concurrent local base refreshes of one repo with real Git', () => { + it('runs one fast-forward and resolves every create without a warning', async () => { + const { repoPath, lockPath, remoteOid } = await createBehindRepoWithIndexLock() + await rm(lockPath, { force: true }) + const spy = spyOnFastForwards() + const warn = vi.spyOn(console, 'warn') + + const results = await Promise.all([refresh(repoPath), refresh(repoPath), refresh(repoPath)]) + + expect(results[0]).toMatchObject({ status: 'updated', ownerWorktreePath: expect.any(String) }) + // The joiners share one follow-up run, which finds local already current. + expect(results.slice(1)).toEqual([undefined, undefined]) + expect(spy.merges()).toBe(1) + expect(warn).not.toHaveBeenCalled() + expect(git(repoPath, ['rev-parse', 'main'])).toBe(remoteOid) + }) +}) + +describe('concurrent local base refreshes toward different remotes with real Git', () => { + it('moves local to a target a later create from another remote asked for', async () => { + const { repoPath, upstreamOid } = await createForkRepo() + const spy = spyOnFastForwards() + + const results = await Promise.all([ + refresh(repoPath, 'origin'), + refresh(repoPath, 'upstream'), + refresh(repoPath, 'origin') + ]) + + expect(results[0]).toMatchObject({ baseRef: 'origin/main', status: 'updated' }) + expect(results[1]).toMatchObject({ baseRef: 'upstream/main', status: 'updated' }) + // Local is now ahead of origin/main: the existing ahead-of-requested-remote rule, not a sharing artifact. + expect(results[2]).toMatchObject({ baseRef: 'origin/main', status: 'skipped_not_fast_forward' }) + expect(git(repoPath, ['rev-parse', 'main'])).toBe(upstreamOid) + expect(spy.maxConcurrent()).toBe(1) + }) + + it('never answers a create with the outcome of another remote target', async () => { + const { repoPath, upstreamOid } = await createForkRepo() + const spy = spyOnFastForwards() + + const results = await Promise.all([ + refresh(repoPath, 'upstream'), + refresh(repoPath, 'upstream'), + refresh(repoPath, 'origin') + ]) + + expect(results[0]).toMatchObject({ baseRef: 'upstream/main', status: 'updated' }) + // Local already equals upstream/main, so the second upstream create has nothing to report. + expect(results[1]).toBeUndefined() + // Local is now ahead of origin/main: the existing ahead-of-requested-remote rule, not a sharing artifact. + expect(results[2]).toMatchObject({ baseRef: 'origin/main', status: 'skipped_not_fast_forward' }) + expect(git(repoPath, ['rev-parse', 'main'])).toBe(upstreamOid) + expect(spy.maxConcurrent()).toBe(1) + }) +}) diff --git a/src/main/git/worktree-base-refresh.ts b/src/main/git/worktree-base-refresh.ts index 862bfc1dedc2..ba0fe4ffc336 100644 --- a/src/main/git/worktree-base-refresh.ts +++ b/src/main/git/worktree-base-refresh.ts @@ -1,14 +1,61 @@ -import type { LocalBaseRefRefreshResult } from '../../shared/worktree/base-ref-drift-types' -import { gitExecFileAsync, translateWslOutputPaths } from './runner' -import { - evaluateLocalBaseRefRefreshability, - getLocalBaseRefUpdateSuggestionForWorktreeCreate -} from './worktree-base-refresh-analysis' +import type { + LocalBaseRefRefreshResult, + LocalBaseRefUpdateSuggestion +} from '../../shared/worktree/base-ref-drift-types' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { createCoalescingKeyedRunner } from '../../shared/coalescing-keyed-runner' import { parseWorktreeList } from '../../shared/git-worktree-porcelain-parser' +import { + fastForwardLocalBaseBranch, + inspectLocalBaseBranch, + toLocalBaseRefRefreshResult, + type LocalBaseBranchFastForwardOutcome, + type LocalBaseBranchGit +} from '../../shared/worktree/local-base-branch-fast-forward' +import { gitExecFileAsync, translateWslOutputPaths } from './runner' +import { runWithGitReadCacheInvalidation } from './status' import type { AddWorktreeOptions, GitWorktreeExecOptions } from './worktree-operation-options' -import { gitExecOptions } from './worktree-operation-options' -export { getLocalBaseRefUpdateSuggestionForWorktreeCreate } +// Why: the create reports the refresh result, and a mutating git process has no timeout; past this +// a create stops waiting and reports nothing, while the one in-flight refresh keeps its checkout. +export const LOCAL_BASE_REF_REFRESH_WAIT_MS = 30_000 + +// Why: two runs moving one checkout at once collide on index.lock and misread each other's half-done +// merge; one run per branch at a time, shared only by creates toward the same target, leaves nothing to race. +const runPerLocalBaseBranch = createCoalescingKeyedRunner() + +export function parseRemoteTrackingLocalBaseRef( + baseBranch: string, + remoteTrackingRef: string, + remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'] +): { baseRef: string; localBranch: string; fullRef: string } | undefined { + if (remoteTrackingBase?.ref === remoteTrackingRef) { + return { + baseRef: remoteTrackingBase.base, + localBranch: remoteTrackingBase.branch, + fullRef: `refs/heads/${remoteTrackingBase.branch}` + } + } + + const remoteRefPrefix = 'refs/remotes/' + if (!remoteTrackingRef.startsWith(remoteRefPrefix)) { + return undefined + } + + // Why: only proven remote-tracking refs get refresh status; slash-containing local branches (release/2026) must not fake a "not refreshed" warning. + const shortRemoteRef = remoteTrackingRef.slice(remoteRefPrefix.length) + const slashIndex = shortRemoteRef.indexOf('/') + if (slashIndex <= 0) { + return undefined + } + + const localBranch = shortRemoteRef.slice(slashIndex + 1) + return { + baseRef: baseBranch, + localBranch, + fullRef: `refs/heads/${localBranch}` + } +} export async function refreshLocalBaseRefForWorktreeCreate( repoPath: string, @@ -17,60 +64,75 @@ export async function refreshLocalBaseRefForWorktreeCreate( remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'], options: GitWorktreeExecOptions = {} ): Promise { - const evaluation = await evaluateLocalBaseRefRefreshability( - repoPath, - baseBranch, - remoteTrackingRef, - remoteTrackingBase, - options - ) - if (!evaluation) { + const parsed = parseRemoteTrackingLocalBaseRef(baseBranch, remoteTrackingRef, remoteTrackingBase) + if (!parsed) { return undefined } - if (!evaluation.refreshable) { - return evaluation.result + const key = `${options.wslDistro ?? ''}\0${normalizeRuntimePathForComparison(repoPath)}\0${parsed.fullRef}` + const git = localBaseBranchGit(options) + const outcome = await waitAtMost( + // Why: the run can outlive this create's wait, so it clears git read caches itself when it moves main. + runPerLocalBaseBranch(key, remoteTrackingRef, () => + runWithGitReadCacheInvalidation(() => + fastForwardLocalBaseBranch(git, { repoPath, fullRef: parsed.fullRef, remoteTrackingRef }) + ) + ), + LOCAL_BASE_REF_REFRESH_WAIT_MS + ) + if (!outcome) { + console.warn( + `addWorktree: stopped waiting for the local ${parsed.localBranch} refresh after ${LOCAL_BASE_REF_REFRESH_WAIT_MS}ms` + ) } + return toLocalBaseRefRefreshResult(parsed, outcome) +} - const resultBase = { baseRef: evaluation.baseRef, localBranch: evaluation.localBranch } - try { - if (evaluation.ownerWorktreePath) { - const { stdout: worktreeListOutput } = await gitExecFileAsync( +export async function getLocalBaseRefUpdateSuggestionForWorktreeCreate( + repoPath: string, + baseBranch: string, + remoteTrackingRef: string, + remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'], + options: GitWorktreeExecOptions = {} +): Promise { + const parsed = parseRemoteTrackingLocalBaseRef(baseBranch, remoteTrackingRef, remoteTrackingBase) + if (!parsed) { + return undefined + } + const inspection = await inspectLocalBaseBranch(localBaseBranchGit(options), { + repoPath, + fullRef: parsed.fullRef, + remoteTrackingRef + }) + return inspection.status === 'behind' + ? { baseRef: parsed.baseRef, localBranch: parsed.localBranch, behind: inspection.behind } + : undefined +} + +function localBaseBranchGit(options: GitWorktreeExecOptions): LocalBaseBranchGit { + // Why: the run is shared by every create that joins it, so no one create's abort signal or timeout may cut it short. + const execOptions = (cwd: string) => ({ + cwd, + ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), + ...(options.admissionTier ? { admissionTier: options.admissionTier } : {}) + }) + return { + exec: (args, cwd) => gitExecFileAsync(args, execOptions(cwd)), + listWorktrees: async (repoPath) => { + const { stdout } = await gitExecFileAsync( ['worktree', 'list', '--porcelain'], - gitExecOptions(repoPath, options) - ) - const worktrees = parseWorktreeList( - translateWslOutputPaths(worktreeListOutput, repoPath, options) - ) - const currentOwner = worktrees.find((wt) => wt.branch === evaluation.fullRef) - if (!currentOwner || currentOwner.path !== evaluation.ownerWorktreePath) { - return { ...resultBase, status: 'skipped_error' } - } - const { stdout: status } = await gitExecFileAsync( - ['status', '--porcelain', '--untracked-files=no'], - gitExecOptions(currentOwner.path, options) + execOptions(repoPath) ) - if (status.trim()) { - return { - ...resultBase, - status: 'skipped_dirty_worktree', - ownerWorktreePath: currentOwner.path - } - } - await gitExecFileAsync( - ['reset', '--hard', evaluation.remoteOid], - gitExecOptions(currentOwner.path, options) - ) - return { ...resultBase, status: 'updated', ownerWorktreePath: currentOwner.path } + return parseWorktreeList(translateWslOutputPaths(stdout, repoPath, options)) } - - // Why: no owner worktree — fast-forward the bare ref; the expected-old-OID form is a no-op-safe CAS if the ref moved since evaluation. - await gitExecFileAsync( - ['update-ref', evaluation.fullRef, evaluation.remoteOid, evaluation.localOid], - gitExecOptions(repoPath, options) - ) - return { ...resultBase, status: 'updated' } - } catch { - // update-ref/reset can fail on locked refs or odd worktree states; worktree creation should still proceed. - return { ...resultBase, status: 'skipped_error' } } } + +function waitAtMost(promise: Promise, timeoutMs: number): Promise { + let timer: ReturnType | undefined + return Promise.race([ + promise, + new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), timeoutMs) + }) + ]).finally(() => clearTimeout(timer)) +} diff --git a/src/main/git/worktree-create-preparation.ts b/src/main/git/worktree-create-preparation.ts index 28330e64e4ff..5a4f65314af8 100644 --- a/src/main/git/worktree-create-preparation.ts +++ b/src/main/git/worktree-create-preparation.ts @@ -190,7 +190,8 @@ export async function finalizePreparedWorktree( repoPath, baseBranch, refreshLocalBaseRef, - finalizeGitOptions + finalizeGitOptions, + branch ) const targetHead = baseContext.effectiveBaseOid ?? @@ -211,10 +212,11 @@ export async function finalizePreparedWorktree( if (targetResult.status === 'rejected') { throw targetResult.reason } + const { baseContext, targetHead } = targetResult.value if (preparedResult.status === 'rejected') { + await baseContext.pendingLocalBaseRefRefresh throw preparedResult.reason } - const { baseContext, targetHead } = targetResult.value const preparedHeadOutput = preparedResult.value.stdout if (preparedHeadOutput.trim() !== targetHead) { await gitExecFileAsync( @@ -275,12 +277,13 @@ export async function finalizePreparedWorktree( moved, finalizeGitOptions ) + await baseContext.pendingLocalBaseRefRefresh throw error } + // Why: the refresh overlapped the finalize above; it has no bearing on the checkout's content. + const localBaseRefRefresh = await baseContext.pendingLocalBaseRefRefresh return { - ...(baseContext.localBaseRefRefresh - ? { localBaseRefRefresh: baseContext.localBaseRefRefresh } - : {}), + ...(localBaseRefRefresh ? { localBaseRefRefresh } : {}), ...(baseContext.localBaseRefUpdateSuggestion ? { localBaseRefUpdateSuggestion: baseContext.localBaseRefUpdateSuggestion } : {}) diff --git a/src/main/git/worktree-deferred-removal-real-git.test.ts b/src/main/git/worktree-deferred-removal-real-git.test.ts deleted file mode 100644 index 06b58b4319ed..000000000000 --- a/src/main/git/worktree-deferred-removal-real-git.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -// Real-binary coverage for deferred worktree deletion: the mocked-runner suite cannot prove that Git -// accepts `worktree remove --force` on a path Orca just renamed away. -import { execFile } from 'node:child_process' -import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { promisify } from 'node:util' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { listWorktreesStrict, removeWorktree } from './worktree' -import { isPrunableGitFileWorktree } from '../worktree-prunable-git-file' -import { removeStaleLocalWorktreeRegistration } from '../local-worktree-removal-recovery' -import { - getWorktreeTrashRoot, - isWorktreeTrashEntryName, - sweepStaleWorktreeTrash, - whenWorktreeTrashDeletionsSettled, - WORKTREE_TRASH_DIR_NAME -} from '../worktree-trash' - -const execFileAsync = promisify(execFile) - -let scratchDir = '' -let repoPath = '' -let workspaceRoot = '' -let worktreePath = '' - -async function git(args: string[], cwd: string): Promise { - const { stdout } = await execFileAsync('git', args, { cwd }) - return stdout -} - -beforeEach(async () => { - // realpath: macOS hands out /var/... temp paths while Git reports /private/var/..., and Orca - // matches the worktree it is removing against Git's own list. - scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-deferred-worktree-removal-'))) - repoPath = join(scratchDir, 'repo') - workspaceRoot = join(scratchDir, 'workspaces') - worktreePath = join(workspaceRoot, 'repo', 'feature') - await mkdir(repoPath, { recursive: true }) - await mkdir(join(workspaceRoot, 'repo'), { recursive: true }) - await git(['init', '-q'], repoPath) - await git(['config', 'user.email', 'deferred@example.invalid'], repoPath) - await git(['config', 'user.name', 'Deferred Removal'], repoPath) - await writeFile(join(repoPath, 'seed.txt'), 'seed\n') - // Committed before the worktree exists so its branch stays merged and branch cleanup can run. - await mkdir(join(repoPath, 'node_modules', 'pkg'), { recursive: true }) - await writeFile(join(repoPath, 'node_modules', 'pkg', 'index.js'), 'module.exports = 1\n') - await git(['add', '-A'], repoPath) - await git(['commit', '-qm', 'seed'], repoPath) - await git(['worktree', 'add', '-q', worktreePath, '-b', 'feature'], repoPath) -}) - -afterEach(async () => { - await whenWorktreeTrashDeletionsSettled() - await rm(scratchDir, { recursive: true, force: true }) -}) - -describe('deferred worktree removal against the real Git binary', () => { - it('clears the registration and deletes the renamed checkout in the background', async () => { - const trashRoot = getWorktreeTrashRoot(worktreePath) - - await removeWorktree(repoPath, worktreePath, false, { deleteBranch: false }) - - // The user-visible removal is complete: nothing on disk, nothing registered. - expect(existsSync(worktreePath)).toBe(false) - expect(await git(['worktree', 'list'], repoPath)).not.toContain(worktreePath) - // Only the rename path creates this root, so its presence proves the deletion was deferred. - expect(existsSync(trashRoot)).toBe(true) - expect((await readdir(trashRoot)).every(isWorktreeTrashEntryName)).toBe(true) - - await whenWorktreeTrashDeletionsSettled() - expect(await readdir(trashRoot)).toEqual([]) - }) - - it('leaves sibling worktrees registered', async () => { - const siblingPath = join(workspaceRoot, 'repo', 'sibling') - await git(['worktree', 'add', '-q', siblingPath, '-b', 'sibling'], repoPath) - - await removeWorktree(repoPath, worktreePath, false, { deleteBranch: false }) - - expect(await git(['worktree', 'list'], repoPath)).toContain(siblingPath) - expect(existsSync(siblingPath)).toBe(true) - }) - - it('deletes the branch exactly as the in-place removal did', async () => { - await removeWorktree(repoPath, worktreePath, false) - - expect(await git(['branch', '--list', 'feature'], repoPath)).toBe('') - }) - - it('refuses to move a dirty checkout aside', async () => { - await writeFile(join(worktreePath, 'seed.txt'), 'edited\n') - - await expect(removeWorktree(repoPath, worktreePath, false)).rejects.toThrow() - expect(existsSync(join(worktreePath, 'seed.txt'))).toBe(true) - expect(await git(['worktree', 'list'], repoPath)).toContain(worktreePath) - expect(existsSync(getWorktreeTrashRoot(worktreePath))).toBe(false) - }) - - it('does not rename a malformed registration that points at the checkout git file', async () => { - const markerPath = join(worktreePath, '.git') - const marker = await readFile(markerPath, 'utf8') - const adminPath = marker.trim().replace(/^gitdir: /, '') - await writeFile(join(adminPath, 'gitdir'), `${join(markerPath, '.git')}\n`) - await writeFile(join(worktreePath, 'untracked.txt'), 'keep this work\n') - - await expect( - removeWorktree(repoPath, markerPath, true, { deleteBranch: false }) - ).rejects.toThrow() - await whenWorktreeTrashDeletionsSettled() - - expect(await readFile(markerPath, 'utf8')).toBe(marker) - expect(await readFile(join(worktreePath, 'untracked.txt'), 'utf8')).toBe('keep this work\n') - expect(await git(['branch', '--list', 'feature'], repoPath)).toContain('feature') - expect(existsSync(getWorktreeTrashRoot(markerPath))).toBe(false) - }) - - it('prunes a proven malformed registration while retaining checkout files and its branch', async () => { - const markerPath = join(worktreePath, '.git') - const marker = await readFile(markerPath, 'utf8') - const adminPath = marker.trim().replace(/^gitdir: /, '') - await writeFile(join(adminPath, 'gitdir'), `${join(markerPath, '.git')}\n`) - await writeFile(join(worktreePath, 'untracked.txt'), 'keep this work\n') - const row = (await listWorktreesStrict(repoPath)).find((entry) => entry.path === markerPath) - expect(row).toBeDefined() - if (!row) { - throw new Error('Missing malformed registration') - } - expect(await isPrunableGitFileWorktree(row)).toBe(true) - - const result = await removeStaleLocalWorktreeRegistration({ - canonicalWorktreePath: markerPath, - repoPath, - localWorktreeGitOptions: {}, - registeredWorktree: row, - deleteBranch: true - }) - - expect(result).toEqual({ preservedBranch: { branchName: 'feature', head: row.head } }) - expect(await readFile(markerPath, 'utf8')).toBe(marker) - expect(await readFile(join(worktreePath, 'untracked.txt'), 'utf8')).toBe('keep this work\n') - expect(await git(['rev-parse', 'refs/heads/feature'], repoPath)).toBe(`${row.head}\n`) - expect((await listWorktreesStrict(repoPath)).some((entry) => entry.path === markerPath)).toBe( - false - ) - expect(existsSync(adminPath)).toBe(false) - }) - - it('sweeps trash a previous run left behind', async () => { - const stalePath = join( - workspaceRoot, - 'repo', - WORKTREE_TRASH_DIR_NAME, - 'wt-1700000000000-deadbeef' - ) - await mkdir(join(stalePath, 'node_modules'), { recursive: true }) - - await sweepStaleWorktreeTrash([workspaceRoot]) - - expect(existsSync(stalePath)).toBe(false) - expect(existsSync(worktreePath)).toBe(true) - }) -}) diff --git a/src/main/git/worktree-delete-limit.test.ts b/src/main/git/worktree-delete-limit.test.ts new file mode 100644 index 000000000000..3c6292dccdec --- /dev/null +++ b/src/main/git/worktree-delete-limit.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { + WORKTREE_DELETE_CONCURRENCY, + _worktreeDeleteLimitSnapshotForTests, + runUnderWorktreeDeleteLimit +} from './worktree-delete-limit' + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +describe('runUnderWorktreeDeleteLimit', () => { + it('runs at most the limit at once and starts queued deletes in arrival order', async () => { + const gates = Array.from({ length: WORKTREE_DELETE_CONCURRENCY + 2 }, deferred) + const started: number[] = [] + const runs = gates.map((gate, index) => + runUnderWorktreeDeleteLimit(async () => { + started.push(index) + await gate.promise + }) + ) + await Promise.resolve() + + expect(started).toEqual([0, 1]) + expect(_worktreeDeleteLimitSnapshotForTests()).toEqual({ running: 2, waiting: 2 }) + + gates[1]?.resolve() + await runs[1] + await Promise.resolve() + expect(started).toEqual([0, 1, 2]) + + gates[0]?.resolve() + gates[2]?.resolve() + gates[3]?.resolve() + await Promise.all(runs) + expect(started).toEqual([0, 1, 2, 3]) + expect(_worktreeDeleteLimitSnapshotForTests()).toEqual({ running: 0, waiting: 0 }) + }) + + it('frees the slot when a delete fails', async () => { + await expect( + runUnderWorktreeDeleteLimit(async () => { + throw new Error('locked') + }) + ).rejects.toThrow('locked') + expect(_worktreeDeleteLimitSnapshotForTests()).toEqual({ running: 0, waiting: 0 }) + }) +}) diff --git a/src/main/git/worktree-delete-limit.ts b/src/main/git/worktree-delete-limit.ts new file mode 100644 index 000000000000..0304f6efe00b --- /dev/null +++ b/src/main/git/worktree-delete-limit.ts @@ -0,0 +1,34 @@ +/** + * Deletes `git worktree remove` runs at once on this host. + * + * Why its own limit instead of general git admission: a large delete holds its child for 20-35 s, + * and general admission can be as small as two slots, so two deletes there would stall every + * status read. Two concurrent deletes already saturate one disk; more only slow each other. + */ +export const WORKTREE_DELETE_CONCURRENCY = 2 + +let running = 0 +const waiting: (() => void)[] = [] + +export async function runUnderWorktreeDeleteLimit(operation: () => Promise): Promise { + if (running >= WORKTREE_DELETE_CONCURRENCY) { + await new Promise((resolve) => waiting.push(resolve)) + } else { + running += 1 + } + try { + return await operation() + } finally { + const next = waiting.shift() + if (next) { + // Why: hand the slot over directly so a new arrival cannot overtake a queued delete. + next() + } else { + running -= 1 + } + } +} + +export function _worktreeDeleteLimitSnapshotForTests(): { running: number; waiting: number } { + return { running, waiting: waiting.length } +} diff --git a/src/main/git/worktree-git-file-restore.ts b/src/main/git/worktree-git-file-restore.ts new file mode 100644 index 000000000000..616aacb2549b --- /dev/null +++ b/src/main/git/worktree-git-file-restore.ts @@ -0,0 +1,58 @@ +import { readdir, readFile, writeFile } from 'node:fs/promises' +import { isAbsolute, join } from 'node:path' +import type { GitWorktreeExecOptions } from './worktree-operation-options' +import { getErrorCode } from './worktree-operation-options' +import { readRepoCommonDirFromGit } from './worktree-list-reader' +import { areWorktreePathsEqual } from './worktree-path-comparison' + +/** + * Rewrites a registered linked worktree's missing `.git` file from Git's own admin entry, the one + * whose `gitdir` points back at it, so `git worktree remove` can validate and delete the checkout. + * + * Why not `git worktree repair` (2.29+): it also re-points every other registered path's `.git`, + * including a checkout another repository now owns at a reused path. + */ +export async function restoreMissingWorktreeGitFile( + repoPath: string, + worktreePath: string, + options: GitWorktreeExecOptions = {} +): Promise { + const commonDir = await readRepoCommonDirFromGit(repoPath, options) + if (!commonDir) { + return false + } + const adminRoot = join(commonDir, 'worktrees') + let entryNames: string[] + try { + entryNames = (await readdir(adminRoot, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + } catch { + return false + } + const gitFilePath = join(worktreePath, '.git') + const owners: string[] = [] + for (const name of entryNames) { + const entryDir = join(adminRoot, name) + const backlink = await readFile(join(entryDir, 'gitdir'), 'utf8').then( + (content) => content.trim(), + () => '' + ) + const resolved = backlink && (isAbsolute(backlink) ? backlink : join(entryDir, backlink)) + if (resolved && areWorktreePathsEqual(resolved, gitFilePath)) { + owners.push(entryDir) + } + } + if (owners.length !== 1) { + return false + } + // Forward slashes are what Git itself writes, and what it compares against on Windows. + const adminDir = process.platform === 'win32' ? owners[0].replaceAll('\\', '/') : owners[0] + try { + await writeFile(gitFilePath, `gitdir: ${adminDir}\n`, { flag: 'wx' }) + } catch (error) { + // Something recreated `.git` meanwhile; Git's own validation judges it. + return getErrorCode(error) === 'EEXIST' + } + return true +} diff --git a/src/main/git/worktree-graph-listing.test.ts b/src/main/git/worktree-graph-listing.test.ts index eecfffe17538..c569ce9e7b86 100644 --- a/src/main/git/worktree-graph-listing.test.ts +++ b/src/main/git/worktree-graph-listing.test.ts @@ -1,17 +1,13 @@ // listWorktreeGraph: -z porcelain listing plus the older-Git usage-error fallback. import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - gitExecFileAsyncMock, - gitExecFileSyncMock, - translateWslOutputPathsMock, - moveWorktreeDirectoryToTrashMock -} = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - gitExecFileSyncMock: vi.fn(), - translateWslOutputPathsMock: vi.fn((output: string) => output), - moveWorktreeDirectoryToTrashMock: vi.fn() -})) +const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted( + () => ({ + gitExecFileAsyncMock: vi.fn(), + gitExecFileSyncMock: vi.fn(), + translateWslOutputPathsMock: vi.fn((output: string) => output) + }) +) vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, @@ -19,13 +15,6 @@ vi.mock('./runner', () => ({ translateWslOutputPaths: translateWslOutputPathsMock })) -// Default: the checkout cannot be renamed aside, so removal deletes it in place. -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined), - restoreWorktreeDirectoryFromTrash: vi.fn().mockResolvedValue(true), - scheduleWorktreeTrashDeletion: vi.fn() -})) - import { listWorktreeGraph, WORKTREE_LIST_TIMEOUT_MS } from './worktree' import { registerWorktreeSuiteHooks } from './worktree-test-harness' diff --git a/src/main/git/worktree-list-porcelain.test.ts b/src/main/git/worktree-list-porcelain.test.ts index 5ef3630639d4..eed9891ac224 100644 --- a/src/main/git/worktree-list-porcelain.test.ts +++ b/src/main/git/worktree-list-porcelain.test.ts @@ -7,26 +7,14 @@ const { translateWslOutputPathsMock, statMock, readFileMock, - resolveGitDirMock, - moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletionMock + resolveGitDirMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), gitExecFileSyncMock: vi.fn(), translateWslOutputPathsMock: vi.fn((output: string) => output), statMock: vi.fn(), readFileMock: vi.fn(), - resolveGitDirMock: vi.fn(), - moveWorktreeDirectoryToTrashMock: vi.fn(), - restoreWorktreeDirectoryFromTrashMock: vi.fn(), - scheduleWorktreeTrashDeletionMock: vi.fn() -})) - -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrash: restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletion: scheduleWorktreeTrashDeletionMock + resolveGitDirMock: vi.fn() })) vi.mock('./runner', () => ({ @@ -58,11 +46,7 @@ const mockGitCommands = createGitCommandMocker(gitExecFileAsyncMock) const getGitCalls = createGitCallReader(gitExecFileAsyncMock) beforeEach(() => { - resetWorktreeRemovalState({ - moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletionMock - }) + resetWorktreeRemovalState() }) describe('listWorktrees', () => { diff --git a/src/main/git/worktree-move.test.ts b/src/main/git/worktree-move.test.ts index 9da3ddbb515f..2832639c1f16 100644 --- a/src/main/git/worktree-move.test.ts +++ b/src/main/git/worktree-move.test.ts @@ -5,13 +5,11 @@ const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock, - moveWorktreeDirectoryToTrashMock, detectSparseCheckoutMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), gitExecFileSyncMock: vi.fn(), translateWslOutputPathsMock: vi.fn((output: string) => output), - moveWorktreeDirectoryToTrashMock: vi.fn(), detectSparseCheckoutMock: vi.fn() })) @@ -21,13 +19,6 @@ vi.mock('./runner', () => ({ translateWslOutputPaths: translateWslOutputPathsMock })) -// Default: the checkout cannot be renamed aside, so removal deletes it in place. -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined), - restoreWorktreeDirectoryFromTrash: vi.fn().mockResolvedValue(true), - scheduleWorktreeTrashDeletion: vi.fn() -})) - vi.mock('./worktree-sparse-state', () => ({ detectSparseCheckout: detectSparseCheckoutMock, resolveGitCommonDir: vi.fn() diff --git a/src/main/git/worktree-mutation-route-invalidation.test.ts b/src/main/git/worktree-mutation-route-invalidation.test.ts index 351a24a1c98d..785ed3d13916 100644 --- a/src/main/git/worktree-mutation-route-invalidation.test.ts +++ b/src/main/git/worktree-mutation-route-invalidation.test.ts @@ -25,13 +25,6 @@ vi.mock('./status', () => ({ runWithGitReadCacheInvalidation: (run: () => Promise) => run() })) -// Default: the checkout cannot be renamed aside, so removal deletes it in place. -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: vi.fn().mockResolvedValue(undefined), - restoreWorktreeDirectoryFromTrash: vi.fn().mockResolvedValue(true), - scheduleWorktreeTrashDeletion: vi.fn() -})) - vi.mock('./worktree', async (importOriginal) => ({ ...(await importOriginal()), resolveWorktreeAddBaseContext: vi.fn(async () => ({ effectiveBase: 'origin/main' })), diff --git a/src/main/git/worktree-operation-options.ts b/src/main/git/worktree-operation-options.ts index 4bf3d9e94514..82c37a1a679d 100644 --- a/src/main/git/worktree-operation-options.ts +++ b/src/main/git/worktree-operation-options.ts @@ -42,6 +42,8 @@ export type RemoveWorktreeOptions = GitWorktreeExecOptions & { deleteBranch?: boolean forceBranchDelete?: boolean knownRemovedWorktree?: Pick + /** Stops only `git worktree remove`; a killed branch cleanup could strand a ref lock. */ + checkoutDeleteSignal?: AbortSignal } // Why: bound `git worktree add` so a OneDrive cloud-placeholder stall fails fast (STA-1292); ample for an ordinary large checkout, but not one behind a slow content filter (#12696). diff --git a/src/main/git/worktree-porcelain-parsing.test.ts b/src/main/git/worktree-porcelain-parsing.test.ts index ae5c1ed569f4..f9009077f6c4 100644 --- a/src/main/git/worktree-porcelain-parsing.test.ts +++ b/src/main/git/worktree-porcelain-parsing.test.ts @@ -1,17 +1,13 @@ // parseWorktreeList: porcelain block parsing, lock/prunable markers, NUL-delimited output. import { describe, expect, it, vi } from 'vitest' -const { - gitExecFileAsyncMock, - gitExecFileSyncMock, - translateWslOutputPathsMock, - moveWorktreeDirectoryToTrashMock -} = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - gitExecFileSyncMock: vi.fn(), - translateWslOutputPathsMock: vi.fn((output: string) => output), - moveWorktreeDirectoryToTrashMock: vi.fn() -})) +const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted( + () => ({ + gitExecFileAsyncMock: vi.fn(), + gitExecFileSyncMock: vi.fn(), + translateWslOutputPathsMock: vi.fn((output: string) => output) + }) +) vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, @@ -19,13 +15,6 @@ vi.mock('./runner', () => ({ translateWslOutputPaths: translateWslOutputPathsMock })) -// Default: the checkout cannot be renamed aside, so removal deletes it in place. -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined), - restoreWorktreeDirectoryFromTrash: vi.fn().mockResolvedValue(true), - scheduleWorktreeTrashDeletion: vi.fn() -})) - import { parseWorktreeList } from './worktree' import { registerWorktreeSuiteHooks } from './worktree-test-harness' @@ -190,7 +179,6 @@ detached HEAD aaa111 branch refs/heads/main - worktree /repo-b HEAD bbb222 branch refs/heads/dev diff --git a/src/main/git/worktree-posix-path-case-sensitivity.test.ts b/src/main/git/worktree-posix-path-case-sensitivity.test.ts index 7e6204a955e7..3c96acbadc3c 100644 --- a/src/main/git/worktree-posix-path-case-sensitivity.test.ts +++ b/src/main/git/worktree-posix-path-case-sensitivity.test.ts @@ -12,31 +12,14 @@ import type * as FsPromises from 'node:fs/promises' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as WslPathTranslation from './command-runner/wsl-path-translation' -const { - gitExecFileAsyncMock, - gitExecFileSyncMock, - statMock, - readFileMock, - resolveGitDirMock, - moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletionMock -} = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - gitExecFileSyncMock: vi.fn(), - statMock: vi.fn(), - readFileMock: vi.fn(), - resolveGitDirMock: vi.fn(), - moveWorktreeDirectoryToTrashMock: vi.fn(), - restoreWorktreeDirectoryFromTrashMock: vi.fn(), - scheduleWorktreeTrashDeletionMock: vi.fn() -})) - -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrash: restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletion: scheduleWorktreeTrashDeletionMock -})) +const { gitExecFileAsyncMock, gitExecFileSyncMock, statMock, readFileMock, resolveGitDirMock } = + vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn(), + gitExecFileSyncMock: vi.fn(), + statMock: vi.fn(), + readFileMock: vi.fn(), + resolveGitDirMock: vi.fn() + })) // Why the real translator: an identity mock removes the Linux -> UNC rewrite that production // always applies, which is the only reason the Linux spelling would ever reach the comparison. @@ -151,11 +134,7 @@ describe('removeWorktree branch selection on a Windows desktop', () => { readFileMock.mockReset() readFileMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) resolveGitDirMock.mockReset() - resetWorktreeRemovalState({ - moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrashMock, - scheduleWorktreeTrashDeletionMock - }) + resetWorktreeRemovalState() }) afterEach(() => { diff --git a/src/main/git/worktree-preparation-base-oid.test.ts b/src/main/git/worktree-preparation-base-oid.test.ts index 5864b9d305ae..ecd6f4eab19a 100644 --- a/src/main/git/worktree-preparation-base-oid.test.ts +++ b/src/main/git/worktree-preparation-base-oid.test.ts @@ -1,9 +1,11 @@ import { beforeEach, expect, it, vi } from 'vitest' +import type * as WorktreeBaseRefresh from './worktree-base-refresh' const gitExec = vi.hoisted(() => vi.fn()) vi.mock('./runner', () => ({ gitExecFileAsync: gitExec })) -vi.mock('./worktree-base-refresh', () => ({ - refreshLocalBaseRefForWorktreeCreate: vi.fn(), +vi.mock('./worktree-base-refresh', async (importOriginal) => ({ + ...(await importOriginal()), + refreshLocalBaseRefForWorktreeCreate: vi.fn(async () => undefined), getLocalBaseRefUpdateSuggestionForWorktreeCreate: vi.fn() })) vi.mock('./status', () => ({ runWithGitReadCacheInvalidation: (run: () => unknown) => run() })) diff --git a/src/main/git/worktree-removal-large-tree.bench.test.ts b/src/main/git/worktree-removal-large-tree.bench.test.ts index debeb6210ed8..02147fdda4a0 100644 --- a/src/main/git/worktree-removal-large-tree.bench.test.ts +++ b/src/main/git/worktree-removal-large-tree.bench.test.ts @@ -1,21 +1,23 @@ -// Manual benchmark for the `worktree.remove.git_remove` stage on a large checkout. -// Opt in (it builds ~100k files): ORCA_WORKTREE_REMOVAL_BENCH=1 pnpm exec vitest run \ -// --config config/vitest.config.ts src/main/git/worktree-removal-large-tree.bench.test.ts +// Manual benchmark: how long removing a large checkout takes, and how slow a store-style write gets +// meanwhile. Opt in (it builds ~100k files): ORCA_WORKTREE_REMOVAL_BENCH=1 ./node_modules/.bin/vitest \ +// run --config config/vitest.config.ts src/main/git/worktree-removal-large-tree.bench.test.ts import { execFile } from 'node:child_process' -import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises' -import { existsSync } from 'node:fs' +import { appendFileSync, existsSync, statSync } from 'node:fs' +import { link, mkdir, mkdtemp, open, readFile, rename, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { performance } from 'node:perf_hooks' import { promisify } from 'node:util' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { removeWorktree } from './worktree' -import { getWorktreeTrashRoot, whenWorktreeTrashDeletionsSettled } from '../worktree-trash' const execFileAsync = promisify(execFile) const describeBench = process.env.ORCA_WORKTREE_REMOVAL_BENCH ? describe : describe.skip const FIXTURE_DIRECTORIES = 200 const FIXTURE_FILES_PER_DIRECTORY = 500 +const STORE_FILE_BYTES = 2 * 1024 * 1024 +const STORE_TRANSACTION_INTERVAL_MS = 20 describeBench('worktree removal on a large checkout', () => { let scratchDir = '' @@ -55,31 +57,81 @@ describeBench('worktree removal on a large checkout', () => { }, 900_000) afterAll(async () => { - await whenWorktreeTrashDeletionsSettled() if (scratchDir) { await rm(scratchDir, { recursive: true, force: true }) } }) - it('returns from removeWorktree without waiting for the recursive delete', async () => { - const startedAt = Date.now() - await removeWorktree(repoPath, worktreePath, false, { deleteBranch: false }) - const userVisibleMs = Date.now() - startedAt + it('reports removal time and store-write latency during the delete', async () => { + // Outside hard links drop to one link only once the checkout's copies are gone, wherever the + // delete runs, so the window covers the whole delete rather than just the call. + const sentinelRoot = join(scratchDir, 'sentinels') + await mkdir(sentinelRoot) + const sentinels: string[] = [] + for (let dirIndex = 0; dirIndex < FIXTURE_DIRECTORIES; dirIndex += 10) { + const sentinel = join(sentinelRoot, `pkg-${dirIndex}`) + await link(join(worktreePath, 'node_modules', `pkg-${dirIndex}`, 'file-0.js'), sentinel) + sentinels.push(sentinel) + } + const checkoutDeleted = (): boolean => sentinels.every((path) => statSync(path).nlink === 1) + const storePath = join(scratchDir, 'store.json') + await writeFile(storePath, Buffer.alloc(STORE_FILE_BYTES, 1)) - const trashRoot = getWorktreeTrashRoot(worktreePath) - console.log( - `[bench] files=${FIXTURE_DIRECTORIES * FIXTURE_FILES_PER_DIRECTORY} removeWorktree=${userVisibleMs}ms` + // Mirrors a persisted-store write: read, write a temp file, fsync it, rename it into place. + const storeTransaction = async (): Promise => { + const startedAt = performance.now() + const contents = await readFile(storePath) + const tempPath = `${storePath}.tmp` + const handle = await open(tempPath, 'w') + try { + await handle.writeFile(contents) + await handle.sync() + } finally { + await handle.close() + } + await rename(tempPath, storePath) + return performance.now() - startedAt + } + const idleMs: number[] = [] + for (let index = 0; index < 10; index += 1) { + idleMs.push(await storeTransaction()) + } + + let removalReturnedMs = 0 + const startedAt = performance.now() + const removal = removeWorktree(repoPath, worktreePath, false, { deleteBranch: false }).then( + () => { + removalReturnedMs = performance.now() - startedAt + } ) + const duringMs: number[] = [] + while (!removalReturnedMs || !checkoutDeleted()) { + const transactionStartedAt = performance.now() + duringMs.push(await storeTransaction()) + const wait = STORE_TRANSACTION_INTERVAL_MS - (performance.now() - transactionStartedAt) + if (wait > 0) { + await new Promise((resolve) => setTimeout(resolve, wait)) + } + } + const deletedMs = performance.now() - startedAt + await removal + + const percentile = (values: number[], p: number): number => { + const sorted = [...values].sort((a, b) => a - b) + return sorted[Math.min(sorted.length - 1, Math.floor((sorted.length * p) / 100))] + } + const line = + `[bench] files=${FIXTURE_DIRECTORIES * FIXTURE_FILES_PER_DIRECTORY}` + + ` removeWorktree=${removalReturnedMs.toFixed(0)}ms checkoutDeleted=${deletedMs.toFixed(0)}ms` + + ` storeIdle p50=${percentile(idleMs, 50).toFixed(1)}ms` + + ` storeDuring n=${duringMs.length} p50=${percentile(duringMs, 50).toFixed(1)}ms` + + ` p90=${percentile(duringMs, 90).toFixed(1)}ms max=${Math.max(...duringMs).toFixed(1)}ms` + console.log(line) + if (process.env.ORCA_WORKTREE_REMOVAL_BENCH_LOG) { + appendFileSync(process.env.ORCA_WORKTREE_REMOVAL_BENCH_LOG, `${line}\n`) + } expect(existsSync(worktreePath)).toBe(false) const { stdout } = await execFileAsync('git', ['worktree', 'list'], { cwd: repoPath }) expect(stdout).not.toContain(worktreePath) - - const backgroundStartedAt = Date.now() - await whenWorktreeTrashDeletionsSettled() - console.log( - `[bench] background deletion=${Date.now() - backgroundStartedAt}ms (trash root ${trashRoot})` - ) - expect(existsSync(trashRoot)).toBe(true) - expect(await readdir(trashRoot)).toEqual([]) }, 900_000) }) diff --git a/src/main/git/worktree-removal-real-git.test.ts b/src/main/git/worktree-removal-real-git.test.ts new file mode 100644 index 000000000000..2df5f7fa46a1 --- /dev/null +++ b/src/main/git/worktree-removal-real-git.test.ts @@ -0,0 +1,235 @@ +// Real-binary coverage for worktree removal: the mocked-runner suite cannot prove what Git deletes, +// deregisters and refuses, or that deleting a checkout leaves Node's file pool free. +import { execFile } from 'node:child_process' +import { existsSync, statSync } from 'node:fs' +import { link, mkdir, mkdtemp, readFile, realpath, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { promisify } from 'node:util' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { removeTree } from '../../shared/windows-transient-lock-removal' +import { listWorktreesStrict, removeWorktree } from './worktree' +import { areWorktreePathsEqual } from './worktree-path-comparison' +import { isPrunableGitFileWorktree } from '../worktree-prunable-git-file' +import { removeStaleLocalWorktreeRegistration } from '../local-worktree-removal-recovery' +import { sweepStaleWorktreeTrash, WORKTREE_TRASH_DIR_NAME } from '../worktree-trash' + +const execFileAsync = promisify(execFile) + +let scratchDir = '' +let repoPath = '' +let workspaceRoot = '' +let worktreePath = '' + +async function git(args: string[], cwd: string): Promise { + const { stdout } = await execFileAsync('git', args, { cwd }) + return stdout +} + +// Why parsed: Git prints forward slashes on Windows, so raw text never contains a joined path. +async function isRegistered(path: string): Promise { + return (await listWorktreesStrict(repoPath)).some((worktree) => + areWorktreePathsEqual(worktree.path, path) + ) +} + +beforeEach(async () => { + // realpath: macOS hands out /var/... temp paths while Git reports /private/var/..., and Orca + // matches the worktree it is removing against Git's own list. + scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-worktree-removal-'))) + repoPath = join(scratchDir, 'repo') + workspaceRoot = join(scratchDir, 'workspaces') + worktreePath = join(workspaceRoot, 'repo', 'feature') + await mkdir(repoPath, { recursive: true }) + await mkdir(join(workspaceRoot, 'repo'), { recursive: true }) + await git(['init', '-q'], repoPath) + await git(['config', 'user.email', 'removal@example.invalid'], repoPath) + await git(['config', 'user.name', 'Worktree Removal'], repoPath) + // Why: a commit's detached auto-maintenance can still be writing packs when teardown deletes the repo. + await git(['config', 'maintenance.auto', 'false'], repoPath) + await git(['config', 'gc.auto', '0'], repoPath) + await writeFile(join(repoPath, 'seed.txt'), 'seed\n') + // Committed before the worktree exists so its branch stays merged and branch cleanup can run. + await mkdir(join(repoPath, 'node_modules', 'pkg'), { recursive: true }) + await writeFile(join(repoPath, 'node_modules', 'pkg', 'index.js'), 'module.exports = 1\n') + await git(['add', '-A'], repoPath) + await git(['commit', '-qm', 'seed'], repoPath) + await git(['worktree', 'add', '-q', worktreePath, '-b', 'feature'], repoPath) +}) + +afterEach(async () => { + await removeTree(scratchDir) +}) + +describe('worktree removal against the real Git binary', () => { + it('deletes the checkout and its registration before returning', async () => { + await removeWorktree(repoPath, worktreePath, false, { deleteBranch: false }) + + expect(existsSync(worktreePath)).toBe(false) + expect(await isRegistered(worktreePath)).toBe(false) + expect(existsSync(join(workspaceRoot, 'repo', WORKTREE_TRASH_DIR_NAME))).toBe(false) + }) + + it('leaves sibling worktrees registered', async () => { + const siblingPath = join(workspaceRoot, 'repo', 'sibling') + await git(['worktree', 'add', '-q', siblingPath, '-b', 'sibling'], repoPath) + + await removeWorktree(repoPath, worktreePath, false, { deleteBranch: false }) + + expect(await isRegistered(siblingPath)).toBe(true) + expect(existsSync(siblingPath)).toBe(true) + }) + + it('deletes the branch exactly as the in-place removal did', async () => { + await removeWorktree(repoPath, worktreePath, false) + + expect(await git(['branch', '--list', 'feature'], repoPath)).toBe('') + }) + + it('refuses to delete a dirty checkout', async () => { + await writeFile(join(worktreePath, 'seed.txt'), 'edited\n') + + await expect(removeWorktree(repoPath, worktreePath, false)).rejects.toThrow() + expect(existsSync(join(worktreePath, 'seed.txt'))).toBe(true) + expect(await isRegistered(worktreePath)).toBe(true) + }) + + it('does not delete a checkout through a malformed registration that names its git file', async () => { + const markerPath = join(worktreePath, '.git') + const marker = await readFile(markerPath, 'utf8') + const adminPath = marker.trim().replace(/^gitdir: /, '') + await writeFile(join(adminPath, 'gitdir'), `${join(markerPath, '.git')}\n`) + await writeFile(join(worktreePath, 'untracked.txt'), 'keep this work\n') + + await expect( + removeWorktree(repoPath, markerPath, true, { deleteBranch: false }) + ).rejects.toThrow() + + expect(await readFile(markerPath, 'utf8')).toBe(marker) + expect(await readFile(join(worktreePath, 'untracked.txt'), 'utf8')).toBe('keep this work\n') + expect(await git(['branch', '--list', 'feature'], repoPath)).toContain('feature') + }) + + it('prunes a proven malformed registration while retaining checkout files and its branch', async () => { + const markerPath = join(worktreePath, '.git') + const marker = await readFile(markerPath, 'utf8') + const adminPath = marker.trim().replace(/^gitdir: /, '') + await writeFile(join(adminPath, 'gitdir'), `${join(markerPath, '.git')}\n`) + await writeFile(join(worktreePath, 'untracked.txt'), 'keep this work\n') + const row = (await listWorktreesStrict(repoPath)).find((entry) => + areWorktreePathsEqual(entry.path, markerPath) + ) + expect(row).toBeDefined() + if (!row) { + throw new Error('Missing malformed registration') + } + expect(await isPrunableGitFileWorktree(row)).toBe(true) + + const result = await removeStaleLocalWorktreeRegistration({ + canonicalWorktreePath: row.path, + repoPath, + localWorktreeGitOptions: {}, + registeredWorktree: row, + deleteBranch: true + }) + + expect(result).toEqual({ preservedBranch: { branchName: 'feature', head: row.head } }) + expect(await readFile(markerPath, 'utf8')).toBe(marker) + expect(await readFile(join(worktreePath, 'untracked.txt'), 'utf8')).toBe('keep this work\n') + expect(await git(['rev-parse', 'refs/heads/feature'], repoPath)).toBe(`${row.head}\n`) + expect(await isRegistered(markerPath)).toBe(false) + expect(existsSync(adminPath)).toBe(false) + }) + + it('sweeps trash an older release left behind', async () => { + const stalePath = join( + workspaceRoot, + 'repo', + WORKTREE_TRASH_DIR_NAME, + 'wt-1700000000000-deadbeef' + ) + await mkdir(join(stalePath, 'node_modules'), { recursive: true }) + + await sweepStaleWorktreeTrash([workspaceRoot]) + + expect(existsSync(stalePath)).toBe(false) + expect(existsSync(worktreePath)).toBe(true) + }) +}) + +const POOL_FIXTURE_FILES = 3_000 +const POOL_SENTINEL_EVERY = 100 + +function queuedFsRequests(): number { + return process + .getActiveResourcesInfo() + .filter((resource) => resource === 'FSReqPromise' || resource === 'FSReqCallback').length +} + +describe('worktree removal and the Node file pool', () => { + it('keeps async file I/O responsive while the checkout is deleted', async () => { + // One flat directory: a recursive delete through the pool would queue every entry at once. + const bulkPath = join(worktreePath, 'bulk') + await mkdir(bulkPath) + for (let start = 0; start < POOL_FIXTURE_FILES; start += 500) { + await Promise.all( + Array.from({ length: 500 }, (_unused, offset) => + writeFile( + join(bulkPath, `file-${start + offset}.js`), + `module.exports = ${start + offset}\n` + ) + ) + ) + } + await git(['add', '-A'], worktreePath) + await git(['commit', '-qm', 'bulk'], worktreePath) + // Outside hard links drop to one link only once the checkout's copies are gone, wherever the + // delete runs, so the window below covers the whole delete rather than just the call. + const sentinelRoot = join(scratchDir, 'sentinels') + await mkdir(sentinelRoot) + const sentinels: string[] = [] + for (let index = 0; index < POOL_FIXTURE_FILES; index += POOL_SENTINEL_EVERY) { + const sentinel = join(sentinelRoot, `file-${index}`) + await link(join(bulkPath, `file-${index}.js`), sentinel) + sentinels.push(sentinel) + } + const checkoutDeleted = (): boolean => sentinels.every((path) => statSync(path).nlink === 1) + const probePath = join(repoPath, 'seed.txt') + + let removalSettled = false + const removal = removeWorktree(repoPath, worktreePath, false, { deleteBranch: false }).finally( + () => { + removalSettled = true + } + ) + const startedAt = performance.now() + let maxQueued = 0 + let maxStatMs = 0 + let statCount = 0 + const sampler = setInterval(() => { + maxQueued = Math.max(maxQueued, queuedFsRequests()) + }, 1) + try { + while (!removalSettled || !checkoutDeleted()) { + expect(performance.now() - startedAt).toBeLessThan(60_000) + maxQueued = Math.max(maxQueued, queuedFsRequests()) + const statStartedAt = performance.now() + await stat(probePath) + maxStatMs = Math.max(maxStatMs, performance.now() - statStartedAt) + statCount += 1 + } + } finally { + clearInterval(sampler) + } + const deletionMs = performance.now() - startedAt + await removal + + console.log( + `[pool] files=${POOL_FIXTURE_FILES} delete=${deletionMs.toFixed(0)}ms stats=${statCount} maxStat=${maxStatMs.toFixed(1)}ms maxQueuedFsRequests=${maxQueued}` + ) + expect(existsSync(worktreePath)).toBe(false) + expect(maxQueued).toBeLessThan(32) + expect(maxStatMs).toBeLessThan(deletionMs / 4) + }, 120_000) +}) diff --git a/src/main/git/worktree-removal.ts b/src/main/git/worktree-removal.ts index afe1bf2a9c10..39d2056f708e 100644 --- a/src/main/git/worktree-removal.ts +++ b/src/main/git/worktree-removal.ts @@ -1,30 +1,26 @@ +import { lstat } from 'node:fs/promises' import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal' +import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args' import { isSubmoduleWorktreeRemovalRefusal } from '../../shared/worktree/submodule-removal' -import { withWorktreeRemoveStageSpan } from '../observability/instrumentation' +import { removeHostTree } from '../host-tree-removal' import { withSpan } from '../observability/tracer' -import { - moveWorktreeDirectoryToTrash, - restoreWorktreeDirectoryFromTrash, - scheduleWorktreeTrashDeletion -} from '../worktree-trash' import { parseWslPath } from '../wsl' import { gitExecFileAsync } from './runner' import { runWithGitReadCacheInvalidation } from './status' import { deleteBranchAfterWorktreeRemoval } from './worktree-branch-removal' -import { listWorktreesStrict } from './worktree-listing' import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing' import type { RemoveWorktreeOptions } from './worktree-operation-options' -import { - WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS, - gitExecOptions, - normalizeLocalBranchRef -} from './worktree-operation-options' +import { getErrorCode, gitExecOptions, normalizeLocalBranchRef } from './worktree-operation-options' import { areWorktreePathsEqual } from './worktree-path-comparison' import { assertWorktreeCleanForRemoval } from './worktree-removal-preflight' import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance' import { bumpWorktreeScanGeneration, listWorktrees } from './worktree-scan-cache' import { invalidateSparseCheckoutState } from './worktree-sparse-checkout-cache' +import { runUnderWorktreeDeleteLimit } from './worktree-delete-limit' +import { runKeyedSerializedOperation } from '../cli/keyed-promise-queue' + +const branchCleanupQueueByRepo = new Map>() /** * Remove a worktree. @@ -69,16 +65,25 @@ async function performRemoveWorktree( // Why: callers outside the IPC/runtime preflight must not bypass Git's lock contract or rely on localized stderr after side effects. assertWorktreeUnlockedForRemoval(removedWorktree) - if ( - !(await tryRemoveWorktreeWithDeferredDirectoryDeletion(repoPath, worktreePath, force, options)) - ) { - const args = ['worktree', 'remove'] - if (force) { - args.push('--force') - } - args.push(worktreePath) + // Why no timeout: this is a write, so none applies by default, and Git deletes the whole checkout + // here (prod p90 29 s); a deadline would kill a legitimate large delete halfway through. + // Why long paths: creation checks out with them on Windows, so deleting without them fails with + // "Filename too long" (#6433) and leaves the branch behind via the Windows recovery. + const longPathArgs = windowsLongPathGitArgs(repoPath) + const execOptions = { + ...gitExecOptions(repoPath, options), + ...(options.checkoutDeleteSignal ? { signal: options.checkoutDeleteSignal } : {}), + ...removalGitEnv(), + admissionExempt: true as const + } + const args = [...longPathArgs, 'worktree', 'remove'] + if (force) { + args.push('--force') + } + args.push(worktreePath) + await runUnderWorktreeDeleteLimit(async () => { try { - await gitExecFileAsync(args, gitExecOptions(repoPath, options)) + await gitExecFileAsync(args, execOptions) } catch (error) { if (force || !isSubmoduleWorktreeRemovalRefusal(error)) { throw error @@ -86,11 +91,12 @@ async function performRemoveWorktree( // Why: Git refuses non-force removal of a worktree with an initialised submodule even when clean; re-prove cleanliness, then --force. await assertWorktreeCleanForRemoval(worktreePath, false, options) await gitExecFileAsync( - ['worktree', 'remove', '--force', worktreePath], - gitExecOptions(repoPath, options) + [...longPathArgs, 'worktree', 'remove', '--force', worktreePath], + execOptions ) } - } + await removeCheckoutLeftByGit(worktreePath, options) + }) if (!branchName) { return {} @@ -99,85 +105,100 @@ async function performRemoveWorktree( return {} } + return deleteBranchOfRemovedWorktree(repoPath, branchName, branchHead, options) +} + +function deleteBranchOfRemovedWorktree( + repoPath: string, + branchName: string, + branchHead: string, + options: RemoveWorktreeOptions +): Promise { // Why its own span: branch cleanup can reach the network (`fetch --prune`), so a stall here reads as // `git worktree remove` being slow unless it is timed separately. - return withSpan('worktree.remove.branch_delete', () => - deleteBranchAfterWorktreeRemoval(repoPath, branchName, branchHead, options) + // Why serialized per repo: concurrent removals in one repo race `packed-refs.lock` and the + // remote-tracking ref locks of `fetch --prune` (#2259); the checkout deletes above need not wait. + return runKeyedSerializedOperation(branchCleanupQueueByRepo, repoPath, () => + withSpan('worktree.remove.branch_delete', () => + deleteBranchAfterWorktreeRemoval(repoPath, branchName, branchHead, options) + ) ) } /** - * Rename the checkout into a sibling trash directory and clear Git's registration for the - * now-missing path, so the multi-GB recursive delete runs after this removal has returned. - * Returns false when the caller must let `git worktree remove` delete the directory inline. + * Finishes a removal whose checkout Git no longer registers (it finished deleting, or an earlier + * run did): leftover files, stale admin records, then the branch. Already-gone parts are done. */ -async function tryRemoveWorktreeWithDeferredDirectoryDeletion( +export async function finishUnregisteredWorktreeRemoval( repoPath: string, worktreePath: string, - force: boolean, + branch: { name: string; head: string } | null, + options: RemoveWorktreeOptions = {} +): Promise { + try { + await runUnderWorktreeDeleteLimit(() => removeCheckoutLeftByGit(worktreePath, options)) + await gitExecFileAsync(['worktree', 'prune'], gitExecOptions(repoPath, options)).catch( + (error: unknown) => console.warn(`[git] worktree prune failed in ${repoPath}`, error) + ) + if (!branch?.name || !(await localBranchExists(repoPath, branch.name, options))) { + return {} + } + return await withRepoRefMaintenancePaused('worktree-remove', () => + deleteBranchOfRemovedWorktree(repoPath, branch.name, branch.head, options) + ) + } finally { + invalidateSparseCheckoutState(repoPath, worktreePath) + bumpWorktreeScanGeneration(repoPath) + } +} + +async function localBranchExists( + repoPath: string, + branchName: string, options: RemoveWorktreeOptions ): Promise { - // Why: WSL-owned checkouts are deleted inside the distro, so Node on Windows must not rename them. - if (options.wslDistro || parseWslPath(worktreePath)) { + try { + await gitExecFileAsync( + ['show-ref', '--verify', '--quiet', '--', `refs/heads/${branchName}`], + gitExecOptions(repoPath, options) + ) + return true + } catch { return false } - if (!force) { - try { - // Why: `git worktree remove` re-checks cleanliness as it removes; prove the same thing here or leave removal to Git. - await assertWorktreeCleanForRemoval(worktreePath, false, options) - } catch { - return false - } - } +} - const trashPath = await withWorktreeRemoveStageSpan('trash_rename', 'local', () => - moveWorktreeDirectoryToTrash(worktreePath) - ) - if (!trashPath) { - return false +// Why: Git for Windows runs $GIT_ASK_YESNO when a file stays locked mid-delete; no prompt program may run here. +function removalGitEnv(): { env?: NodeJS.ProcessEnv } { + const inherited = Object.keys(process.env).filter((key) => key.toUpperCase() === 'GIT_ASK_YESNO') + if (inherited.length === 0) { + return {} } - try { - await clearGitRegistrationForMissingWorktree(repoPath, worktreePath, options) - } catch (error) { - // Why: put the checkout back so the in-place removal below still sees the worktree Git registered. - if (await restoreWorktreeDirectoryFromTrash(trashPath, worktreePath)) { - return false - } - throw error + const env = { ...process.env } + for (const key of inherited) { + delete env[key] } - scheduleWorktreeTrashDeletion(trashPath) - return true + return { env } } -async function clearGitRegistrationForMissingWorktree( - repoPath: string, +// Why: Git for Windows does not descend into junctions and exits 0 with them and their parent +// directories still on disk; finish the delete Git already accepted instead of leaving it behind. +async function removeCheckoutLeftByGit( worktreePath: string, options: RemoveWorktreeOptions ): Promise { - const registrationOptions = { - ...options, - timeout: options.timeout ?? WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS + // Why: WSL-owned checkouts are deleted inside the distro, so Node on Windows must not touch them. + if (options.wslDistro || parseWslPath(worktreePath)) { + return } try { - // Removing an already-missing directory is accepted back to the Git 2.25 baseline and touches only this entry. - await gitExecFileAsync( - ['worktree', 'remove', '--force', worktreePath], - gitExecOptions(repoPath, registrationOptions) - ) - return + await lstat(worktreePath) } catch (error) { - console.warn( - `[git] Failed to deregister the moved worktree "${worktreePath}"; pruning instead`, - error - ) - } - - await gitExecFileAsync(['worktree', 'prune'], gitExecOptions(repoPath, registrationOptions)) - // Strict (not the shared scan): an unreadable repo must not read as proof that the row is gone. - const stillRegistered = (await listWorktreesStrict(repoPath, registrationOptions)).some( - (worktree) => areWorktreePathsEqual(worktree.path, worktreePath) - ) - if (stillRegistered) { - throw new Error(`Git still reports a registration for "${worktreePath}" after pruning it.`) + if (getErrorCode(error) === 'ENOENT') { + return + } + throw error } + console.warn(`[git] worktree remove left files at "${worktreePath}"; deleting them`) + await removeHostTree(worktreePath) } diff --git a/src/main/git/worktree-remove-branch-deletion.test.ts b/src/main/git/worktree-remove-branch-deletion.test.ts index e00ee48c0e0f..6469d675e9f0 100644 --- a/src/main/git/worktree-remove-branch-deletion.test.ts +++ b/src/main/git/worktree-remove-branch-deletion.test.ts @@ -1,17 +1,13 @@ // removeWorktree: branch deletion safety after the checkout is removed. -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - gitExecFileAsyncMock, - gitExecFileSyncMock, - translateWslOutputPathsMock, - moveWorktreeDirectoryToTrashMock -} = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - gitExecFileSyncMock: vi.fn(), - translateWslOutputPathsMock: vi.fn((output: string) => output), - moveWorktreeDirectoryToTrashMock: vi.fn() -})) +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' + +const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted( + () => ({ + gitExecFileAsyncMock: vi.fn(), + gitExecFileSyncMock: vi.fn(), + translateWslOutputPathsMock: vi.fn((output: string) => output) + }) +) vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, @@ -19,13 +15,6 @@ vi.mock('./runner', () => ({ translateWslOutputPaths: translateWslOutputPathsMock })) -// Default: the checkout cannot be renamed aside, so removal deletes it in place. -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined), - restoreWorktreeDirectoryFromTrash: vi.fn().mockResolvedValue(true), - scheduleWorktreeTrashDeletion: vi.fn() -})) - import { removeWorktree } from './worktree' import { registerWorktreeSuiteHooks } from './worktree-test-harness' @@ -35,15 +24,22 @@ describe('removeWorktree', () => { const beforeRemoval = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\nworktree /repo-feature\nHEAD def456\nbranch refs/heads/feature/test\n' + // Why: removal argv carries core.longpaths on Windows; pin a non-Windows default for exact argv. + let platformSpy: MockInstance<() => NodeJS.Platform> + beforeEach(() => { gitExecFileAsyncMock.mockReset() gitExecFileSyncMock.mockReset() translateWslOutputPathsMock.mockClear() + platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + }) + + afterEach(() => { + platformSpy.mockRestore() }) it('uses safe `branch -d` and preserves a branch with unmerged commits', async () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: beforeRemoval }) // list before - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // clean probe before the rename attempt gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove // Git refuses to delete an unmerged branch with `-d`. gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not fully merged')) // branch -d @@ -60,7 +56,6 @@ describe('removeWorktree', () => { it('deletes the branch when `branch -d` succeeds (fully merged)', async () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: beforeRemoval }) // list before - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // clean probe before the rename attempt gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // branch -d succeeds @@ -75,7 +70,6 @@ describe('removeWorktree', () => { }) it('reuses known removed worktree metadata instead of relisting before removal', async () => { - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // clean probe before the rename attempt gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // branch -d succeeds @@ -87,7 +81,6 @@ describe('removeWorktree', () => { }) expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toEqual([ - ['status', '--porcelain', '--untracked-files=all'], ['worktree', 'remove', '/repo-feature'], ['branch', '-d', '--', 'feature/test'] ]) @@ -95,7 +88,6 @@ describe('removeWorktree', () => { it('prunes and retries branch deletion only when Git reports a checked-out branch', async () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: beforeRemoval }) // list before - gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // clean probe before the rename attempt gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove gitExecFileAsyncMock.mockRejectedValueOnce( new Error("error: cannot delete branch 'feature/test' used by worktree at '/repo-stale'") @@ -107,7 +99,6 @@ describe('removeWorktree', () => { expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toEqual([ ['worktree', 'list', '--porcelain', '-z'], - ['status', '--porcelain', '--untracked-files=all'], ['worktree', 'remove', '/repo-feature'], ['branch', '-d', '--', 'feature/test'], ['worktree', 'prune'], diff --git a/src/main/git/worktree-scan-cache-sharing.test.ts b/src/main/git/worktree-scan-cache-sharing.test.ts index c42bec7e7301..eabe471a1c64 100644 --- a/src/main/git/worktree-scan-cache-sharing.test.ts +++ b/src/main/git/worktree-scan-cache-sharing.test.ts @@ -2,17 +2,13 @@ import { worktreeCreateGit } from './worktree-create-git-executor' // Worktree scan sharing: in-flight coalescing and mutation-generation retirement. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { - gitExecFileAsyncMock, - gitExecFileSyncMock, - translateWslOutputPathsMock, - moveWorktreeDirectoryToTrashMock -} = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - gitExecFileSyncMock: vi.fn(), - translateWslOutputPathsMock: vi.fn((output: string) => output), - moveWorktreeDirectoryToTrashMock: vi.fn() -})) +const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted( + () => ({ + gitExecFileAsyncMock: vi.fn(), + gitExecFileSyncMock: vi.fn(), + translateWslOutputPathsMock: vi.fn((output: string) => output) + }) +) vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, @@ -20,13 +16,6 @@ vi.mock('./runner', () => ({ translateWslOutputPaths: translateWslOutputPathsMock })) -// Default: the checkout cannot be renamed aside, so removal deletes it in place. -vi.mock('../worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined), - restoreWorktreeDirectoryFromTrash: vi.fn().mockResolvedValue(true), - scheduleWorktreeTrashDeletion: vi.fn() -})) - import { _getWorktreeScanCacheSizesForTests, _resetWorktreeScanCacheForTests, diff --git a/src/main/gitea/repository-ref.test.ts b/src/main/gitea/repository-ref.test.ts index 678dd5c6912d..ddd4076fcbd0 100644 --- a/src/main/gitea/repository-ref.test.ts +++ b/src/main/gitea/repository-ref.test.ts @@ -127,42 +127,6 @@ describe('Gitea repository ref parsing', () => { }) }) - it('keeps local host and local WSL repository-ref cache entries separate', async () => { - gitExecFileAsyncMock - .mockResolvedValueOnce({ - stdout: 'https://git.example.com/host/project.git\n', - stderr: '' - }) - .mockResolvedValueOnce({ - stdout: 'https://git.example.com/wsl/project.git\n', - stderr: '' - }) - - await expect(getGiteaRepoRef('/repo')).resolves.toMatchObject({ - owner: 'host', - repo: 'project' - }) - await expect(getGiteaRepoRef('/repo', null, { wslDistro: 'Ubuntu' })).resolves.toMatchObject({ - owner: 'wsl', - repo: 'project' - }) - await expect(getGiteaRepoRef('/repo', null, { wslDistro: 'Ubuntu' })).resolves.toMatchObject({ - owner: 'wsl', - repo: 'project' - }) - - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) - expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['remote', 'get-url', 'origin'], { - cwd: '/repo', - timeout: REMOTE_URL_PROBE_TIMEOUT_MS - }) - expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['remote', 'get-url', 'origin'], { - cwd: '/repo', - wslDistro: 'Ubuntu', - timeout: REMOTE_URL_PROBE_TIMEOUT_MS - }) - }) - it('bounds cached repository refs for distinct repo paths', async () => { gitExecFileAsyncMock.mockResolvedValue({ stdout: 'https://git.example.com/team/project.git\n', @@ -194,22 +158,4 @@ describe('Gitea repository ref parsing', () => { }) expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) - - it('does not cache transient SSH provider failures as unsupported repos', async () => { - sshExecMock.mockRejectedValueOnce(new Error('connection closed')).mockResolvedValueOnce({ - stdout: 'git@gitea.example.test:remote/project.git\n', - stderr: '' - }) - registerSshGitProvider('conn-1', { exec: sshExecMock } as never) - - await expect(getGiteaRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toBeNull() - await expect(getGiteaRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toMatchObject({ - host: 'gitea.example.test', - owner: 'remote', - repo: 'project' - }) - - expect(sshExecMock).toHaveBeenCalledTimes(2) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) }) diff --git a/src/main/github/client-issue-source.test.ts b/src/main/github/client-issue-source.test.ts index d9c593be3a0a..a4f8b6468314 100644 --- a/src/main/github/client-issue-source.test.ts +++ b/src/main/github/client-issue-source.test.ts @@ -730,23 +730,6 @@ describe('GitHub issue source split', () => { expect(result.issueSourceFellBack).toBeUndefined() }) - it("preference='auto' + no upstream → queries origin", async () => { - resolveIssueSourceMock.mockResolvedValueOnce({ - source: { owner: 'solo', repo: 'orca' }, - fellBack: false - }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'solo', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({ - stdout: '[]' - }) - - await listWorkItems('/repo-root', 10, undefined, undefined, 'auto') - - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, issueSearchArgs('solo/orca'), { - cwd: '/repo-root' - }) - }) - it("preference='auto' + upstream exists → PRs query upstream too", async () => { // Why: fork-contribution PRs live on the upstream repo — the fork's own // PR list is almost always empty. 'auto' must resolve PRs upstream-first @@ -799,22 +782,6 @@ describe('GitHub issue source split', () => { ) }) - it("preference='upstream' + upstream exists → queries upstream", async () => { - resolveIssueSourceMock.mockResolvedValueOnce({ - source: { owner: 'stablyai', repo: 'orca' }, - fellBack: false - }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({ - stdout: '[]' - }) - - const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'upstream') - - expect(decodedIssueSearchPath(0)).toContain('q=repo:stablyai/orca is:issue is:open') - expect(result.issueSourceFellBack).toBeUndefined() - }) - it("preference='upstream' + no upstream → falls back to origin with fellBack=true", async () => { resolveIssueSourceMock.mockResolvedValueOnce({ source: { owner: 'solo', repo: 'orca' }, @@ -831,36 +798,6 @@ describe('GitHub issue source split', () => { expect(result.issueSourceFellBack).toBe(true) }) - it("preference='origin' + upstream exists → queries origin (not upstream)", async () => { - resolveIssueSourceMock.mockResolvedValueOnce({ - source: { owner: 'fork', repo: 'orca' }, - fellBack: false - }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({ - stdout: '[]' - }) - - await listWorkItems('/repo-root', 10, undefined, undefined, 'origin') - - expect(decodedIssueSearchPath(0)).toContain('q=repo:fork/orca is:issue is:open') - }) - - it("preference='origin' + no upstream → queries origin", async () => { - resolveIssueSourceMock.mockResolvedValueOnce({ - source: { owner: 'solo', repo: 'orca' }, - fellBack: false - }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'solo', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({ - stdout: '[]' - }) - - await listWorkItems('/repo-root', 10, undefined, undefined, 'origin') - - expect(decodedIssueSearchPath(0)).toContain('q=repo:solo/orca is:issue is:open') - }) - it('surfaces upstreamCandidate in sources regardless of effective preference', async () => { // Why: the renderer selector needs to keep rendering after the user picks // 'origin'. That requires the envelope to carry the raw upstream even diff --git a/src/main/github/client-pr-push-target.test.ts b/src/main/github/client-pr-push-target.test.ts index daaa05155ae6..36fd97823f3b 100644 --- a/src/main/github/client-pr-push-target.test.ts +++ b/src/main/github/client-pr-push-target.test.ts @@ -144,32 +144,6 @@ describe('getPRForBranch', () => { }) }) - it('omits maintainerCanModify when the API does not report the flag', async () => { - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) - getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - head: { - ref: 'fix-sidebar', - repo: { - full_name: 'stablyai/orca', - name: 'orca', - clone_url: 'https://github.com/stablyai/orca.git', - ssh_url: 'git@github.com:stablyai/orca.git', - owner: { login: 'stablyai' } - } - } - }) - }) - - await expect(getPullRequestPushTarget('/repo-root', 1738)).resolves.toEqual({ - pushTarget: { - remoteName: 'origin', - branchName: 'fix-sidebar' - } - }) - }) - it('uses origin for same-repository PR push targets', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) diff --git a/src/main/github/gh-utils.test.ts b/src/main/github/gh-utils.test.ts index 5400eaeb5654..c239dc026f20 100644 --- a/src/main/github/gh-utils.test.ts +++ b/src/main/github/gh-utils.test.ts @@ -149,16 +149,6 @@ describe('github owner/repo resolution', () => { expect(gitRemoteGetUrlCalls('origin')).toHaveLength(1) }) - it('prefers upstream for issue owner/repo resolution', async () => { - mockGitRemoteCommands({ - origin: 'git@github.com:fork/orca.git\n', - upstream: 'git@github.com:stablyai/orca.git\n' - }) - - await expect(getIssueOwnerRepo('/repo')).resolves.toEqual({ owner: 'stablyai', repo: 'orca' }) - expect(gitRemoteGetUrlCalls('upstream')).toHaveLength(1) - }) - it('falls back to origin when upstream is present but non-GitHub', async () => { mockGitRemoteCommands({ origin: 'git@github.com:fork/orca.git\n', diff --git a/src/main/github/github-repository-identity.fork-owner-repo.test.ts b/src/main/github/github-repository-identity.fork-owner-repo.test.ts index e0cd7de6ca45..9c6f2a21159a 100644 --- a/src/main/github/github-repository-identity.fork-owner-repo.test.ts +++ b/src/main/github/github-repository-identity.fork-owner-repo.test.ts @@ -30,7 +30,7 @@ vi.mock('./local-git-config-signature', () => ({ import { _resetRemoteNameListingCache } from '../git/remote-name-listing' import { getOwnerRepoForRemote, _resetOwnerRepoCache } from './github-repository-identity' -import { getOwnerRepo, getIssueOwnerRepo } from './github-owner-repo-selection' +import { getOwnerRepo } from './github-owner-repo-selection' import { getRepoUpstream } from './client' const FORK_PATH = '/tmp/fork-checkout' @@ -84,13 +84,6 @@ describe('issue #7331: fork PR owner/repo resolution', () => { expect(prRepo).toEqual({ owner: 'stablyai', repo: 'orca' }) }) - it('getOwnerRepo and getIssueOwnerRepo agree on a fork checkout', async () => { - const prRepo = await getOwnerRepo(FORK_PATH) - const issueRepo = await getIssueOwnerRepo(FORK_PATH) - - expect(prRepo).toEqual(issueRepo) - }) - it('getOwnerRepo falls back to origin when there is no upstream remote', async () => { const prRepo = await getOwnerRepo(NON_FORK_PATH) diff --git a/src/main/github/work-item-details.test.ts b/src/main/github/work-item-details.test.ts index 11a063b51ba6..3ec1a5a4d9ff 100644 --- a/src/main/github/work-item-details.test.ts +++ b/src/main/github/work-item-details.test.ts @@ -708,76 +708,6 @@ describe('getWorkItemDetails', () => { expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 42, 'pr', null, {}, 'origin') }) - // Why: a rate-limited/auth-failed file fetch must not render as an empty PR; - // the Files tab keys its retry state off details.filesUnavailable. - it('flags filesUnavailable when the PR file fetch fails but leaves the PR empty otherwise intact', async () => { - getWorkItemMock.mockResolvedValueOnce({ - id: 'pr:8305', - type: 'pr', - number: 8305, - title: 'Files fetch fails', - state: 'open', - url: 'https://github.com/acme/widgets/pull/8305', - labels: [], - updatedAt: '2026-07-11T00:00:00Z', - author: 'pr-author' - }) - getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - getPRCommentsMock.mockResolvedValue([]) - getPRChecksMock.mockResolvedValue([]) - ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { - const target = args.at(-1) - if (target === 'repos/acme/widgets/pulls/8305') { - return { - stdout: JSON.stringify({ head: { sha: 'head-sha' }, base: { sha: 'base-sha' } }) - } - } - if (target === 'repos/acme/widgets/pulls/8305/files?per_page=100') { - throw new Error('gh: API rate limit exceeded (403)') - } - return { stdout: JSON.stringify({ data: {} }) } - }) - - const details = await getWorkItemDetails('/repo-root', 8305, 'pr') - - expect(details?.filesUnavailable).toBe(true) - expect(details?.files).toBeUndefined() - }) - - it('treats an empty file list as a genuinely empty PR, not an unavailable one', async () => { - getWorkItemMock.mockResolvedValueOnce({ - id: 'pr:8306', - type: 'pr', - number: 8306, - title: 'Empty PR', - state: 'open', - url: 'https://github.com/acme/widgets/pull/8306', - labels: [], - updatedAt: '2026-07-11T00:00:00Z', - author: 'pr-author' - }) - getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - getPRCommentsMock.mockResolvedValue([]) - getPRChecksMock.mockResolvedValue([]) - ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { - const target = args.at(-1) - if (target === 'repos/acme/widgets/pulls/8306') { - return { - stdout: JSON.stringify({ head: { sha: 'head-sha' }, base: { sha: 'base-sha' } }) - } - } - if (target === 'repos/acme/widgets/pulls/8306/files?per_page=100') { - return { stdout: '[]' } - } - return { stdout: JSON.stringify({ data: {} }) } - }) - - const details = await getWorkItemDetails('/repo-root', 8306, 'pr') - - expect(details?.filesUnavailable).toBe(false) - expect(details?.files).toEqual([]) - }) - // Why: `gh pr view` omits avatar_url, so the login-based github.com URL 404s on // GHE. getWorkItemDetails must resolve author/reviewer/assignee avatars via the // GraphQL user(login:) batch and stamp them onto the returned item. See #8784. diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index f460be06b5fe..e6fecfc06ee0 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -36,6 +36,7 @@ export function registerAgentHookHandlers( ipcMain.removeHandler('agentStatus:getSnapshot') ipcMain.removeHandler('agentStatus:inferInterrupt') ipcMain.removeHandler('agentStatus:inferQuestionAnswered') + ipcMain.removeHandler('agentStatus:hasVerifiableAgentProcess') ipcMain.removeHandler('agentStatus:getMigrationUnsupportedSnapshot') registerAgentStatusRowTeardownIpcHandlers() registerAgentPaneAuthorityIpcHandlers({ @@ -69,6 +70,9 @@ export function registerAgentHookHandlers( } return agentHookServer.inferQuestionAnswered(request as AgentQuestionAnsweredInferenceRequest) }) + ipcMain.handle('agentStatus:hasVerifiableAgentProcess', (_event, paneKey: unknown): boolean => + typeof paneKey === 'string' ? agentHookServer.hasVerifiableAgentProcess(paneKey) : false + ) ipcMain.handle( 'agentStatus:getMigrationUnsupportedSnapshot', (): MigrationUnsupportedPtyEntry[] => getMigrationUnsupportedPtySnapshot() diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts index 4a8fbab955a2..a8adaacc0a3d 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts @@ -21,11 +21,13 @@ import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../shared/protocol-version' +import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from '../../shared/agent-session-background-task-child-views-capability' import { supportsAgentLaunch } from '../runtime/rpc/methods/agent-launch' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from './desktop-renderer-runtime-capabilities' @@ -47,15 +49,19 @@ const REMOTE_ONLY_BY_DECISION: readonly RuntimeCapability[] = [ BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, // Opts into a delta feed in place of the full tab list — a remote-transport concern. - SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY + SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, + // Main marks `removing` on its own worktree IPC listings unconditionally; the renderer never + // lists its own host's worktrees over runtime RPC, where this capability decides mark vs omit. + WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY ] /** Gates the renderer must pass against its own main process. The Electron remote list omits all - * five; mobile advertises the structured ones, so this is an Electron-remote gap rather than a + * six; mobile advertises the structured ones, so this is an Electron-remote gap rather than a * statement that no remote client wants them. Why it is one is not recorded here. */ const LOCAL_ONLY_BY_DECISION: readonly RuntimeCapability[] = [ AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.ts index 0bcfd1bd686d..8fe881e00614 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.ts @@ -9,6 +9,7 @@ import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../shared/protocol-version' +import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from '../../shared/agent-session-background-task-child-views-capability' /** * What the desktop renderer advertises when it calls its own main process over `runtime:call`. @@ -29,6 +30,7 @@ export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapab AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, // Without this `supportsAgentLaunch` refuses the renderer outright, while the same renderer diff --git a/src/main/ipc/notification-options.ts b/src/main/ipc/notification-options.ts index 24d43545e138..b06987c4b75c 100644 --- a/src/main/ipc/notification-options.ts +++ b/src/main/ipc/notification-options.ts @@ -76,12 +76,24 @@ function formatAgentNotificationStatusText(args: NotificationDispatchRequest): s if (args.agentState === 'working') { return translateMain('notifications.agentStatus.working', 'working') } - if (args.agentState === 'done' && args.agentTurnOutcome === 'failure') { - return translateMain('notifications.agentStatus.failed', 'failed') + if (args.agentState !== 'done') { + return translateMain('notifications.agentStatus.finished', 'finished') + } + switch (args.agentTurnOutcome) { + // A turn cut short by anything but the user is a fault, as a failure is. + case 'failure': + case 'interruption': + return translateMain('notifications.agentStatus.failed', 'failed') + // Why: a Stop the user asked for, a turn a newer request replaced, or an end Orca cannot + // prove, still never reads finished. + case 'cancellation': + case 'superseded': + case 'unconfirmed': + return translateMain('notifications.agentStatus.stopped', 'stopped') + case 'success': + case undefined: + return translateMain('notifications.agentStatus.finished', 'finished') } - return args.agentState === 'done' && args.agentTurnOutcome === 'cancellation' - ? translateMain('notifications.agentStatus.stopped', 'stopped') - : translateMain('notifications.agentStatus.finished', 'finished') } function formatNotificationWorktreeContext(args: NotificationDispatchRequest): string { diff --git a/src/main/ipc/notifications-message-formatting.test.ts b/src/main/ipc/notifications-message-formatting.test.ts index 3d519293f3c8..bf96745fb4fb 100644 --- a/src/main/ipc/notifications-message-formatting.test.ts +++ b/src/main/ipc/notifications-message-formatting.test.ts @@ -319,6 +319,10 @@ describe('registerNotificationHandlers', () => { it.each([ { agentTurnOutcome: 'cancellation', word: 'stopped' }, { agentTurnOutcome: 'failure', word: 'failed' }, + // A turn cut short by anything but the user is a fault, worded as one. + { agentTurnOutcome: 'interruption', word: 'failed' }, + { agentTurnOutcome: 'unconfirmed', word: 'stopped' }, + { agentTurnOutcome: 'superseded', word: 'stopped' }, { agentTurnOutcome: 'success', word: 'finished' }, { agentTurnOutcome: undefined, word: 'finished' } ] as const)('words a $agentTurnOutcome finish as $word', async ({ agentTurnOutcome, word }) => { diff --git a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts index bed6953e2b12..f248fb8eb0c8 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -1,9 +1,8 @@ import { withFreshOmpLaunch } from '../../shared/omp-fresh-launch' import { describe, expect, it, vi } from 'vitest' import { piBuildPtyEnvMock, spawnMock } from './pty-ipc-mock-registry' -import { BUNDLED_CLI_PATH, TEST_CODEX_HOME, makeDisposable } from './pty-ipc-test-constants' +import { TEST_CODEX_HOME, makeDisposable } from './pty-ipc-test-constants' import { setupPtyIpcSuite } from './pty-ipc-test-harness' -import { delimiter } from 'node:path' import { LocalPtyProvider } from '../providers/local-pty-provider' import { __resetPersistedWindowsPathCacheForTests } from '../pty/windows-environment-path' import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader' @@ -416,34 +415,9 @@ describe('registerPtyHandlers', () => { ) expect(env.CODEX_HOME).toBe(TEST_CODEX_HOME) expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME) - // Why (STA-4270): a bare name would be resolved by the post-profile PATH the codex() - // wrapper inherits, so the preflight must carry the CLI's verified absolute path. - expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).toBe(BUNDLED_CLI_PATH) - }) - it('skips the Codex launch preflight when the bundled CLI is not executable', async () => { - const env = await withBundledCli( - () => spawnAndGetEnv(undefined, undefined, () => TEST_CODEX_HOME), - { launcherExecutable: false } - ) - - expect(env.CODEX_HOME).toBe(TEST_CODEX_HOME) + // Why: the app prepares a native pane's Codex home; only WSL panes run the preflight. expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).toBeUndefined() }) - // Why (STA-4270): profile scripts run before the codex() wrapper and routinely prepend - // directories to PATH, so a scratch `orca` there must never become the preflight. - it('pins the Codex launch preflight to the bundled CLI even when PATH leads elsewhere', async () => { - const env = await withBundledCli(() => - spawnAndGetEnv( - { PATH: `/tmp/hijack-scratch${delimiter}/usr/bin` }, - undefined, - () => TEST_CODEX_HOME - ) - ) - - expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).toBe(BUNDLED_CLI_PATH) - expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).not.toBe('orca') - expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT.startsWith('/tmp/hijack-scratch')).toBe(false) - }) it('does not install the Codex launch preflight when Codex hooks are disabled', async () => { const env = await spawnAndGetEnv( undefined, diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index 7d8250629031..324dbe60dc7a 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -291,7 +291,6 @@ export function buildPtyHostEnv( isPackaged: opts.isPackaged, isWsl: opts.isWsl, managedHomePath: opts.selectedCodexHomePath, - userDataPath: opts.userDataPath, resourcesPath: opts.resourcesPath }) if (preflightCommand) { diff --git a/src/main/ipc/ssh-target-crud-handlers.ts b/src/main/ipc/ssh-target-crud-handlers.ts index 5d9a416039b7..e8b7a95c4784 100644 --- a/src/main/ipc/ssh-target-crud-handlers.ts +++ b/src/main/ipc/ssh-target-crud-handlers.ts @@ -34,8 +34,15 @@ function takeRepoReadoptions(): SshRepoReadoption[] { return repoReadoptions } -function omitRendererSshTargetGeneration(value: T): Omit { - const { generation: _generation, ...rest } = value as T & { generation?: unknown } +// Why: generations and the runtime ladder cache are main-owned; a renderer must not forge them. +function omitRendererSshTargetGeneration< + T extends { generation?: unknown; remoteRuntimeResolution?: unknown } +>(value: T): Omit { + const { + generation: _generation, + remoteRuntimeResolution: _remoteRuntimeResolution, + ...rest + } = value return rest } diff --git a/src/main/ipc/telemetry.ts b/src/main/ipc/telemetry.ts index ad954b20ce19..83cc65795918 100644 --- a/src/main/ipc/telemetry.ts +++ b/src/main/ipc/telemetry.ts @@ -30,7 +30,8 @@ const MAIN_OWNED_TELEMETRY_EVENTS = new Set([ 'daemon_pty_cwd_denied', 'daemon_pty_cwd_readable', 'star_nag_outcome', - 'feature_interaction_usage_bucket_reached' + 'feature_interaction_usage_bucket_reached', + 'ssh_remote_runtime_resolved' ]) /** diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index 27e3f9f1a699..a502e8023dd0 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -288,30 +288,12 @@ describe('computeBranchName', () => { }) describe('getConfiguredBranchPrefix', () => { - it('returns the git username for the git-username strategy', () => { - expect(getConfiguredBranchPrefix({ branchPrefix: 'git-username' }, 'jdoe')).toBe('jdoe') - }) - - it('returns null for git-username when no username is available', () => { - expect(getConfiguredBranchPrefix({ branchPrefix: 'git-username' }, null)).toBeNull() - }) - - it('returns the custom value for the custom strategy', () => { - expect( - getConfiguredBranchPrefix({ branchPrefix: 'custom', branchPrefixCustom: 'team' }, null) - ).toBe('team') - }) - it('returns null for custom strategy with an empty value', () => { expect( getConfiguredBranchPrefix({ branchPrefix: 'custom', branchPrefixCustom: '' }, null) ).toBeNull() }) - it('returns null when no prefix strategy applies', () => { - expect(getConfiguredBranchPrefix({ branchPrefix: 'none' }, 'jdoe')).toBeNull() - }) - it('normalizes a trailing slash out of the custom prefix', () => { expect( getConfiguredBranchPrefix({ branchPrefix: 'custom', branchPrefixCustom: 'team/' }, null) diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index ce11df4f4f01..256cb87fe9fc 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -45,10 +45,7 @@ import { getBranchConflictKindViaExec } from '../git/repo-branch-conflict' import { WorktreeCreateCollisionError } from '../../shared/new-workspace/worktree-create-collision' import { resolveLocalGitUsername, getSshGitUsername } from '../git/git-username' import { hasCommitObjectViaGitExec } from '../git/commit-object-ref' -import { - hasLocalWorktreeBaseRef, - probeWorktreeBaseRefPresence -} from '../git/worktree-base-ref-probe' +import { hasLocalWorktreeBaseRef } from '../git/worktree-base-ref-probe' import { resolveWorktreeCreateBase } from '../worktree-create-base' import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref' import { getHostedReviewForBranch } from '../source-control/hosted-review' @@ -170,6 +167,9 @@ import { retireGeneratedWorktreeName } from '../worktree-name-retirement' import { createRetiredNameLookup } from '../../shared/worktree/retired-name-registry' +import { toLocalBaseRefRefreshResult } from '../../shared/worktree/local-base-branch-fast-forward' +import { isSshRequestOutcomeUnverifiable } from '../ssh/ssh-channel-multiplexer' +import { findPendingWorktreeRemovalConflict } from '../worktree-background-removal' const SSH_WORKTREE_CREATE_FETCH_FRESHNESS_MS = 30_000 const SSH_WORKTREE_CREATE_FETCH_CACHE_MAX = 512 @@ -222,22 +222,6 @@ type StagedStartupResult = { warning?: string } -type RemoteLocalBaseRefRefreshability = - | { - refreshable: true - baseRef: string - localBranch: string - fullRef: string - remoteTrackingRef: string - behind: number - ownerWorktreePath?: string - } - | { - refreshable: false - // undefined = nothing to refresh (no local branch yet), so the caller reports no status at all. - result: LocalBaseRefRefreshResult | undefined - } - function appendWorktreeCreateWarning(current: string | undefined, next: string): string { return current ? `${current} Also ${next[0]?.toLowerCase() ?? ''}${next.slice(1)}` : next } @@ -385,10 +369,6 @@ export function recordWorkspaceLineageForCreatedWorktree( return { lineage, workspaceLineage } } -function countNonEmptyGitOutputLines(output: string): number { - return output.split(/\r?\n/).filter((line) => line.trim().length > 0).length -} - async function spawnLocalStartupAndSetupTerminals(args: { runtime: OrcaRuntimeService | undefined worktree: Pick @@ -439,7 +419,8 @@ async function spawnLocalStartupAndSetupTerminals(args: { ...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}), startupCommandDelivery: sequencedStartup.startupCommandDelivery, telemetry: sequencedStartup.telemetry, - activate: true + // Why: the submitting renderer decides whether to open the workspace; activating here yanked users who moved on (#9944). + surfaceOwner: false }) startupTerminalHandle = terminal.handle startupTerminal = { @@ -477,14 +458,16 @@ async function spawnLocalStartupAndSetupTerminals(args: { direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical', command: setupCommand, env: setup.envVars, - activate: false + activate: false, + surfaceOwner: false }) } else { await runtime.createTerminal(`id:${worktree.id}`, { title: 'Setup', command: setupCommand, env: setup.envVars, - activate: false + activate: false, + surfaceOwner: false }) } didSpawnSetup = true @@ -1634,121 +1617,28 @@ export async function prefetchRemoteWorktreeCreateBase( await fetchRemoteForWorktreeCreate(provider, repo, 'origin') } +/** Never rejects: the create may already have succeeded when this settles. */ async function refreshLocalBaseRefForRemoteWorktreeCreate( provider: SshGitProvider, repoPath: string, remoteTrackingBase: RemoteTrackingBase ): Promise { - const evaluation = await evaluateRemoteLocalBaseRefRefreshability( - provider, - repoPath, - remoteTrackingBase - ) - if (!evaluation.refreshable) { - return evaluation.result - } - - const resultBase = { baseRef: evaluation.baseRef, localBranch: evaluation.localBranch } + const names = { baseRef: remoteTrackingBase.base, localBranch: remoteTrackingBase.branch } try { - await provider.refreshLocalBaseRefForWorktreeCreate({ + // Why: the relay owns the whole refresh on the execution host, one run per branch at a time. + const outcome = await provider.refreshLocalBaseRefForWorktreeCreate({ repoPath, - fullRef: evaluation.fullRef, - remoteTrackingRef: evaluation.remoteTrackingRef, - ...(evaluation.ownerWorktreePath ? { ownerWorktreePath: evaluation.ownerWorktreePath } : {}) + fullRef: `refs/heads/${remoteTrackingBase.branch}`, + remoteTrackingRef: remoteTrackingBase.ref }) - return { - ...resultBase, - status: 'updated', - ...(evaluation.ownerWorktreePath ? { ownerWorktreePath: evaluation.ownerWorktreePath } : {}) - } - } catch { - return { ...resultBase, status: 'skipped_error' } - } -} - -async function evaluateRemoteLocalBaseRefRefreshability( - provider: SshGitProvider, - repoPath: string, - remoteTrackingBase: RemoteTrackingBase, - shouldInspectOwner: (behind: number) => boolean = () => true -): Promise { - const resultBase = { - baseRef: remoteTrackingBase.base, - localBranch: remoteTrackingBase.branch - } - const fullRef = `refs/heads/${remoteTrackingBase.branch}` - - let behind = 0 - try { - // Why: SSH generic git.exec is allowlisted — merge-base and log are permitted read-only probes; rev-list is intentionally not exposed. - await provider.exec(['merge-base', '--is-ancestor', fullRef, remoteTrackingBase.ref], repoPath) - const { stdout } = await provider.exec( - ['log', '--format=%H', `${fullRef}..${remoteTrackingBase.ref}`], - repoPath - ) - behind = countNonEmptyGitOutputLines(stdout) - if (!shouldInspectOwner(behind)) { - // Why: no behind commits means no update to advise; skip remote worktree/status round trips. - return { - refreshable: true, - ...resultBase, - fullRef, - remoteTrackingRef: remoteTrackingBase.ref, - behind - } - } - } catch { - // Why (#15331): the probes above also fail when refs/heads/ is simply absent; the relay's - // `worktree add -b` is about to create it, so there is nothing stale to warn about. Only a proven - // absence suppresses: a dropped relay connection is not evidence the branch is missing. - const presence = await probeWorktreeBaseRefPresence( - (args) => provider.exec(args, repoPath), - fullRef - ) - if (presence === 'absent') { - return { refreshable: false, result: undefined } - } - return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } } - } - - try { - const worktrees = await provider.listWorktrees(repoPath) - const ownerWorktree = worktrees.find((wt) => wt.branch === fullRef) - - if (ownerWorktree) { - const status = await provider.worktreeIsClean(ownerWorktree.path, { - includeUntracked: false - }) - if (!status.clean) { - return { - refreshable: false, - result: { - ...resultBase, - status: 'skipped_dirty_worktree', - ownerWorktreePath: ownerWorktree.path - } - } - } - return { - refreshable: true, - ...resultBase, - fullRef, - remoteTrackingRef: remoteTrackingBase.ref, - behind, - ownerWorktreePath: ownerWorktree.path - } - } - - // Why: not checked out anywhere, so a bare-ref fast-forward is safe; omitting ownerWorktreePath tells the relay to update-ref, not reset --hard. - return { - refreshable: true, - ...resultBase, - fullRef, - remoteTrackingRef: remoteTrackingBase.ref, - behind + return toLocalBaseRefRefreshResult(names, outcome) + } catch (error) { + if (isSshRequestOutcomeUnverifiable(error)) { + // Why: the host may still finish it; claiming it failed would be a guess. + console.warn('[worktree-create] local base ref refresh outcome unknown', error) + return undefined } - } catch { - return { refreshable: false, result: { ...resultBase, status: 'skipped_error' } } + return toLocalBaseRefRefreshResult(names, { status: 'skipped_error' }) } } @@ -1757,31 +1647,18 @@ async function getRemoteLocalBaseRefUpdateSuggestionForWorktreeCreate( repoPath: string, remoteTrackingBase: RemoteTrackingBase ): Promise { - const evaluation = await evaluateRemoteLocalBaseRefRefreshability( - provider, - repoPath, - remoteTrackingBase, - (behind) => behind > 0 - ) - if (!evaluation.refreshable || evaluation.behind <= 0) { - return undefined - } try { - await provider.refreshLocalBaseRefForWorktreeCreate({ + const behind = await provider.getLocalBaseRefFastForwardableBehind({ repoPath, - fullRef: evaluation.fullRef, - remoteTrackingRef: evaluation.remoteTrackingRef, - ...(evaluation.ownerWorktreePath ? { ownerWorktreePath: evaluation.ownerWorktreePath } : {}), - checkOnly: true + fullRef: `refs/heads/${remoteTrackingBase.branch}`, + remoteTrackingRef: remoteTrackingBase.ref }) + return behind === undefined + ? undefined + : { baseRef: remoteTrackingBase.base, localBranch: remoteTrackingBase.branch, behind } } catch { return undefined } - return { - baseRef: evaluation.baseRef, - localBranch: evaluation.localBranch, - behind: evaluation.behind - } } export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string): void { @@ -2035,9 +1912,14 @@ export async function createRemoteWorktree( } } - const localBaseRefRefresh = - settings.refreshLocalBaseRefOnWorktreeCreate && !checkoutExistingBranch && remoteTrackingBase - ? await refreshLocalBaseRefForRemoteWorktreeCreate(provider, repo.path, remoteTrackingBase) + // Why: started, not awaited — the relay adds from the remote-tracking ref, so the refresh overlaps the checkout. + // A create of the base's own local branch is skipped: `-b` proves it did not exist, and probing it would race the add. + const pendingLocalBaseRefRefresh = + settings.refreshLocalBaseRefOnWorktreeCreate && + !checkoutExistingBranch && + remoteTrackingBase && + remoteTrackingBase.branch !== branchName + ? refreshLocalBaseRefForRemoteWorktreeCreate(provider, repo.path, remoteTrackingBase) : undefined const localBaseRefUpdateSuggestion = !settings.refreshLocalBaseRefOnWorktreeCreate && @@ -2062,6 +1944,7 @@ export async function createRemoteWorktree( // (#17828) instead of paying it at create time for a read-only review. const preparedPushTarget: GitPushTarget | undefined = args.pushTarget + let localBaseRefRefresh: LocalBaseRefRefreshResult | undefined try { await timing.time('git_worktree_add', async () => provider.addWorktree( @@ -2085,6 +1968,9 @@ export async function createRemoteWorktree( ) } throw err + } finally { + // Why: a sparse rollback below must not race a refresh still moving the owner checkout. + localBaseRefRefresh = await pendingLocalBaseRefRefresh } // Why: the worktree is listable from here on; a scan that began before it appeared is overtaken. runWorktreeChangeInvalidators(repo.id) @@ -2627,7 +2513,12 @@ async function performLocalWorktreeCreate( computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings, workspaceRoot), workspaceRoot ) - if (existsSync(worktreePath)) { + // Why the pending check: Git may already have deleted the directory while Orca still owns + // the path until its removal settles; take the next name instead of racing it. + if ( + existsSync(worktreePath) || + findPendingWorktreeRemovalConflict(repo.path, { worktreePath }) + ) { continue } diff --git a/src/main/ipc/worktrees-background-removal.test.ts b/src/main/ipc/worktrees-background-removal.test.ts new file mode 100644 index 000000000000..ead826e7d629 --- /dev/null +++ b/src/main/ipc/worktrees-background-removal.test.ts @@ -0,0 +1,310 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + addWorktreeMock, + getEffectiveHooksMock, + listWorktreesMock, + removeWorktreeMock, + runHookMock +} from './worktrees-test-module-mocks' +import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' +import { mockKnownFeatureWorktree } from './worktrees-test-fixtures' +import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' +import { + _resetPendingWorktreeRemovalsForTests, + startBackgroundWorktreeRemoval +} from '../worktree-background-removal' + +vi.mock('electron', async () => + (await import('./worktrees-test-module-mocks')).electronModuleMock() +) +vi.mock('../git/worktree', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() +) +vi.mock('../git/runner', async () => + (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() +) +vi.mock('../git/repo', async () => + (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +) +vi.mock('../git/git-username', async (importOriginal) => ({ + ...(await importOriginal>()), + resolveLocalGitUsername: (await import('./worktrees-test-module-mocks')) + .resolveLocalGitUsernameMock +})) +vi.mock('../github/client', async () => + (await import('./worktrees-test-module-mocks')).githubClientModuleMock() +) +vi.mock('../source-control/hosted-review', async () => + (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() +) +vi.mock('../providers/ssh-git-dispatch', async () => + (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() +) +vi.mock('../providers/ssh-filesystem-dispatch', async () => + (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() +) +vi.mock('./worktree-symlinks', async () => + (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() +) +vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) +vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) +vi.mock('../setup-runner-script-text', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../worktree-runner-script', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../effective-hook-config', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../setup-hook-env-vars', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('./worktree-logic', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../terminal-history-deletion', async () => + (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() +) +vi.mock('../ports/advertised-url-watcher', async () => + (await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock() +) +vi.mock('../workspace-cleanup-scan-snapshot', async () => + (await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock() +) +vi.mock('../workspace-space-analysis-snapshot', async () => + (await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock() +) +vi.mock('../workspace-cleanup-removal-snapshot-prune', async () => + (await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock() +) +vi.mock('../runtime/worktree-teardown', async () => + (await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock() +) +vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) + +function remove(args: Record): Promise { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: worktrees:remove resolves a RemoveWorktreeResult. + return handlers['worktrees:remove'](null, args) as Promise +} + +type ListedRow = { id: string; removing?: true } + +async function listRepoRows(): Promise { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: worktrees:list resolves the repo's Worktree rows. + return (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as ListedRow[] +} + +function blockGitRemove(): { + release: (result?: RemoveWorktreeResult) => void + fail: (error: Error) => void +} { + let release!: (result?: RemoveWorktreeResult) => void + let fail!: (error: Error) => void + removeWorktreeMock.mockImplementation( + () => + new Promise((resolve, reject) => { + release = (result = {}) => resolve(result) + fail = reject + }) + ) + return { + release: (result) => release(result), + fail: (error) => fail(error) + } +} + +const featureId = 'repo-1::/workspace/feature-wt' + +describe('worktrees:remove in the background', () => { + beforeEach(() => { + setupWorktreeHandlers() + }) + + afterEach(() => { + _resetPendingWorktreeRemovalsForTests() + }) + + it('replies once Git finishes, listing the row as removing until then', async () => { + const worktrees = mockKnownFeatureWorktree() + const git = blockGitRemove() + + const reply = remove({ worktreeId: featureId }) + await vi.waitFor(() => expect(removeWorktreeMock).toHaveBeenCalledTimes(1)) + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + + const during = await listRepoRows() + expect(during.find((row) => row.id === featureId)?.removing).toBe(true) + expect(during.find((row) => row.id === 'repo-1::/workspace/repo')?.removing).toBeUndefined() + + listWorktreesMock.mockResolvedValue([worktrees[0]]) + git.release({ preservedBranch: { branchName: 'feature', head: 'feature' } }) + + const result = await reply + expect(result).toMatchObject({ preservedBranch: { branchName: 'feature', head: 'feature' } }) + expect(result.removing).toBeUndefined() + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(featureId, 'local') + const after = await listRepoRows() + expect(after.map((row) => row.id)).not.toContain(featureId) + }) + + it('replies with the delete error and lists the row normally again', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + mockKnownFeatureWorktree() + const git = blockGitRemove() + + const reply = remove({ worktreeId: featureId }) + await vi.waitFor(() => expect(removeWorktreeMock).toHaveBeenCalledTimes(1)) + git.fail(new Error('permission denied')) + + await expect(reply).rejects.toThrow('permission denied') + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + const rows = await listRepoRows() + expect(rows.find((row) => row.id === featureId)).toBeDefined() + expect(rows.find((row) => row.id === featureId)?.removing).toBeUndefined() + + // A retry is a fresh removal, not a join onto the failed one. + removeWorktreeMock.mockResolvedValue({}) + await expect(remove({ worktreeId: featureId })).resolves.not.toHaveProperty('removing') + expect(removeWorktreeMock).toHaveBeenCalledTimes(2) + }) + + it('joins a repeat delete, with any options, onto the removal Git is running', async () => { + mockKnownFeatureWorktree() + const git = blockGitRemove() + + const first = remove({ worktreeId: featureId }) + await vi.waitFor(() => expect(removeWorktreeMock).toHaveBeenCalledTimes(1)) + const repeat = remove({ worktreeId: featureId, force: true, hostId: 'local' }) + + git.release({ preservedBranch: { branchName: 'feature', head: 'feature' } }) + const preserved = { preservedBranch: { branchName: 'feature', head: 'feature' } } + await expect(first).resolves.toMatchObject(preserved) + await expect(repeat).resolves.toMatchObject(preserved) + expect(removeWorktreeMock).toHaveBeenCalledTimes(1) + }) + + it('runs same-repo archive hooks one at a time while their Git deletes overlap', async () => { + const [main, feature] = mockKnownFeatureWorktree() + const secondId = 'repo-1::/workspace/second-wt' + listWorktreesMock.mockResolvedValue([ + main, + feature, + { ...feature, path: '/workspace/second-wt', branch: 'second', head: 'second' } + ]) + getEffectiveHooksMock.mockReturnValue({ scripts: { archive: 'git update-ref refs/x HEAD' } }) + const hookReleases: (() => void)[] = [] + runHookMock.mockImplementation( + () => + new Promise((resolve) => hookReleases.push(() => resolve({ success: true, output: '' }))) + ) + const gitReleases: (() => void)[] = [] + removeWorktreeMock.mockImplementation( + () => new Promise((resolve) => gitReleases.push(() => resolve({}))) + ) + + const first = remove({ worktreeId: featureId }) + const second = remove({ worktreeId: secondId }) + await vi.waitFor(() => expect(runHookMock).toHaveBeenCalledTimes(1)) + // Let the second request reach the host's acceptance queue before checking it waits there. + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(runHookMock).toHaveBeenCalledTimes(1) + + hookReleases[0]?.() + await vi.waitFor(() => expect(runHookMock).toHaveBeenCalledTimes(2)) + hookReleases[1]?.() + await vi.waitFor(() => expect(removeWorktreeMock).toHaveBeenCalledTimes(2)) + + for (const release of gitReleases) { + release() + } + await expect(first).resolves.not.toHaveProperty('removing') + await expect(second).resolves.not.toHaveProperty('removing') + }) + + it('joins a removal another client got accepted while this request waited its turn', async () => { + const [main, feature] = mockKnownFeatureWorktree() + const secondId = 'repo-1::/workspace/second-wt' + listWorktreesMock.mockResolvedValue([ + main, + feature, + { ...feature, path: '/workspace/second-wt', branch: 'second', head: 'second' } + ]) + getEffectiveHooksMock.mockReturnValue({ scripts: { archive: 'true' } }) + let releaseHook!: () => void + runHookMock.mockResolvedValue({ success: true, output: '' }) + runHookMock.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseHook = () => resolve({ success: true, output: '' }) + }) + ) + removeWorktreeMock.mockResolvedValue({}) + + const holdsTurn = remove({ worktreeId: secondId }) + await vi.waitFor(() => expect(runHookMock).toHaveBeenCalledTimes(1)) + const queued = remove({ worktreeId: featureId }) + await new Promise((resolve) => setTimeout(resolve, 20)) + // Stands in for the runtime path (CLI, paired clients), which coalesces apart from this one. + let finishOther: ((result: RemoveWorktreeResult) => void) | undefined + void startBackgroundWorktreeRemoval({ + removal: { + worktreeId: featureId, + repoId: 'repo-1', + repoPath: '/workspace/repo', + worktree: feature, + deleteBranch: true, + force: false + }, + run: () => + new Promise((resolve) => { + finishOther = resolve + }), + publish: () => {} + }) + releaseHook() + await holdsTurn + await vi.waitFor(() => expect(finishOther).toBeDefined()) + + const preserved = { preservedBranch: { branchName: 'feature', head: 'feature' } } + finishOther?.(preserved) + await expect(queued).resolves.toMatchObject(preserved) + expect(runHookMock).toHaveBeenCalledTimes(1) + expect(removeWorktreeMock).toHaveBeenCalledTimes(1) + }) + + it('gives a create with the same name the next free name while the delete runs', async () => { + mockKnownFeatureWorktree() + blockGitRemove() + void remove({ worktreeId: featureId }).catch(() => {}) + await vi.waitFor(() => expect(removeWorktreeMock).toHaveBeenCalledTimes(1)) + + addWorktreeMock.mockResolvedValue({}) + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/feature-wt-2', + head: 'abc', + branch: 'feature-wt-2', + isBare: false, + isMainWorktree: false + } + ]) + await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'feature-wt' }) + + expect(addWorktreeMock).toHaveBeenCalledTimes(1) + expect(addWorktreeMock.mock.calls[0]?.[1]).toBe('/workspace/feature-wt-2') + }) +}) diff --git a/src/main/ipc/worktrees-delete-pty-teardown.test.ts b/src/main/ipc/worktrees-delete-pty-teardown.test.ts index 4326278446eb..7fec88c15ad9 100644 --- a/src/main/ipc/worktrees-delete-pty-teardown.test.ts +++ b/src/main/ipc/worktrees-delete-pty-teardown.test.ts @@ -145,7 +145,11 @@ describe('registerWorktreeHandlers', () => { ]) expect(store.removeWorktreeMeta).toHaveBeenCalledTimes(1) expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledTimes(1) - expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + // One notice when the row starts showing as removing, one after it has left the table. + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(2) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('worktrees:changed', { + repoId: 'repo-1' + }) }) it('rejects concurrent deletes for the same worktree id with different options', async () => { @@ -155,11 +159,13 @@ describe('registerWorktreeHandlers', () => { const started = new Promise((resolve) => { removalStarted = resolve }) - removeWorktreeMock.mockImplementation( + // Held in acceptance: once accepted, a repeat delete joins the background removal instead. + killAllProcessesForWorktreeMock.mockImplementation( () => - new Promise((resolve) => { + new Promise((resolve) => { removalStarted() - finishRemoval = resolve + finishRemoval = () => + resolve({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) }) ) @@ -176,9 +182,10 @@ describe('registerWorktreeHandlers', () => { }) ).rejects.toThrow('Worktree deletion already in progress') - expect(removeWorktreeMock).toHaveBeenCalledTimes(1) + expect(removeWorktreeMock).not.toHaveBeenCalled() finishRemoval() await expect(first).resolves.toEqual({ catalogVersion: anyCatalogVersion }) + expect(removeWorktreeMock).toHaveBeenCalledTimes(1) }) it('still rejects forced unregistered delete paths that exist on disk', async () => { diff --git a/src/main/ipc/worktrees-local-create-flow.test.ts b/src/main/ipc/worktrees-local-create-flow.test.ts index 72da89bb9624..7da7756bd111 100644 --- a/src/main/ipc/worktrees-local-create-flow.test.ts +++ b/src/main/ipc/worktrees-local-create-flow.test.ts @@ -686,7 +686,7 @@ describe('registerWorktreeHandlers', () => { launch_source: 'new_workspace_composer', request_kind: 'new' }, - activate: true + surfaceOwner: false } ) expect(runtimeStub.createTerminal).toHaveBeenNthCalledWith( @@ -699,7 +699,8 @@ describe('registerWorktreeHandlers', () => { ORCA_ROOT_PATH: '/workspace/repo', ORCA_WORKTREE_PATH: '/workspace/improve-dashboard' }, - activate: false + activate: false, + surfaceOwner: false } ) const startupCreateCall = runtimeStub.createTerminal.mock.calls[0] @@ -707,6 +708,9 @@ describe('registerWorktreeHandlers', () => { if (!startupCreateCall || !setupCreateCall) { throw new Error('expected startup and setup terminal calls') } + // The submitting renderer decides whether to open the new workspace, so the host must not + // activate it for the startup terminal (#9944). + expect(startupCreateCall[1]).not.toHaveProperty('activate') const startupCommand = (startupCreateCall[1] as { command: string }).command const setupCommand = (setupCreateCall[1] as { command: string }).command expect(startupCommand).toBe('claude --prefill test') @@ -782,6 +786,47 @@ describe('registerWorktreeHandlers', () => { expect(result.setup?.command).toContain('printf') }) + it('splits split-mode setup into the startup terminal without surfacing the workspace', async () => { + addWorktreeMock.mockResolvedValue({}) + listWorktreesMock.mockResolvedValueOnce([ + { + path: '/workspace/improve-dashboard', + head: 'def', + branch: 'improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + store.getSettings.mockReturnValue({ + branchPrefix: 'none', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + workspaceDir: '/workspace', + setupScriptLaunchMode: 'split-vertical' + }) + loadHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + getEffectiveHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + shouldRunSetupForCreateMock.mockReturnValue(true) + + await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + createdWithAgent: 'claude', + startup: { command: 'claude' } + }) + + expect(runtimeStub.createTerminal).toHaveBeenCalledTimes(1) + // A user who moved on must not be scrolled to the new workspace by its setup pane (#9944). + expect(runtimeStub.splitTerminal).toHaveBeenCalledWith('term-startup', { + direction: 'vertical', + command: expect.stringContaining('setup-runner.sh'), + env: expect.any(Object), + activate: false, + surfaceOwner: false + }) + }) + it('rejects ask-policy creates before mutating git state when setup decision is missing', async () => { getEffectiveHooksMock.mockReturnValue({ scripts: { diff --git a/src/main/ipc/worktrees-removal-recovery.test.ts b/src/main/ipc/worktrees-removal-recovery.test.ts index d3231d50bd87..b1e91daea9d7 100644 --- a/src/main/ipc/worktrees-removal-recovery.test.ts +++ b/src/main/ipc/worktrees-removal-recovery.test.ts @@ -452,9 +452,6 @@ describe('registerWorktreeHandlers', () => { expect.anything() ) expect(store.removeWorktreeMeta).not.toHaveBeenCalled() - expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('worktrees:changed', { - repoId: 'repo-1' - }) } finally { removePathSpy.mockRestore() } diff --git a/src/main/ipc/worktrees-ssh-local-base-refresh-overlap.test.ts b/src/main/ipc/worktrees-ssh-local-base-refresh-overlap.test.ts new file mode 100644 index 000000000000..59794f82ba71 --- /dev/null +++ b/src/main/ipc/worktrees-ssh-local-base-refresh-overlap.test.ts @@ -0,0 +1,309 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getSshGitProviderMock, getActiveMultiplexerMock } from './worktrees-test-module-mocks' +import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' +import { SSH_MUX_REQUEST_TIMEOUT_CODE } from '../ssh/ssh-channel-multiplexer' + +vi.mock('electron', async () => + (await import('./worktrees-test-module-mocks')).electronModuleMock() +) +vi.mock('../git/worktree', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() +) +vi.mock('../git/runner', async () => + (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() +) +vi.mock('../git/repo', async () => + (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +) +vi.mock('../git/git-username', async (importOriginal) => ({ + ...(await importOriginal>()), + resolveLocalGitUsername: (await import('./worktrees-test-module-mocks')) + .resolveLocalGitUsernameMock +})) +vi.mock('../github/client', async () => + (await import('./worktrees-test-module-mocks')).githubClientModuleMock() +) +vi.mock('../source-control/hosted-review', async () => + (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() +) +vi.mock('../providers/ssh-git-dispatch', async () => + (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() +) +vi.mock('../providers/ssh-filesystem-dispatch', async () => + (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() +) +vi.mock('./worktree-symlinks', async () => + (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() +) +vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) +vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) +vi.mock('../setup-runner-script-text', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( + await importOriginal>() + ) +) +vi.mock('../worktree-runner-script', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( + await importOriginal>() + ) +) +vi.mock('../effective-hook-config', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( + await importOriginal>() + ) +) +vi.mock('../setup-hook-env-vars', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( + await importOriginal>() + ) +) +vi.mock('./worktree-logic', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( + await importOriginal>() + ) +) +vi.mock('../terminal-history-deletion', async () => + (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() +) +vi.mock('../ports/advertised-url-watcher', async () => + (await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock() +) +vi.mock('../workspace-cleanup-scan-snapshot', async () => + (await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock() +) +vi.mock('../workspace-space-analysis-snapshot', async () => + (await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock() +) +vi.mock('../workspace-cleanup-removal-snapshot-prune', async () => + (await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock() +) +vi.mock('../runtime/worktree-teardown', async () => + (await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock() +) +vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) + +const REPO = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/main' +} +const REFRESH_UPDATED = { status: 'updated', ownerWorktreePath: '/remote/repo' } +const UPDATED = { + status: 'updated', + baseRef: 'origin/main', + localBranch: 'main', + ownerWorktreePath: '/remote/repo' +} + +function createProvider(overrides: { + refreshLocalBaseRefForWorktreeCreate: ReturnType + addWorktree?: ReturnType +}) { + return { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'show-ref') { + throw Object.assign(new Error('missing remote ref'), { code: 1 }) + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: overrides.addWorktree ?? vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/repo-improve-dashboard', + head: 'remote-main', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }), + refreshLocalBaseRefForWorktreeCreate: overrides.refreshLocalBaseRefForWorktreeCreate + } +} + +async function createWith( + provider: ReturnType, + request: { repo?: typeof REPO; name?: string } = {} +) { + const repo = request.repo ?? REPO + store.getSettings.mockReturnValue({ + branchPrefix: 'none', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: true, + workspaceDir: '/workspace' + }) + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue({ + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + }) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + const result: unknown = await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: request.name ?? 'improve-dashboard' + }) + return result +} + +describe('SSH local base refresh failures', () => { + beforeEach(() => { + setupWorktreeHandlers() + }) + + // The relay may still finish the refresh; reporting it failed would be a guess. + it.each([ + ['the request timed out', { code: SSH_MUX_REQUEST_TIMEOUT_CODE }], + ['the connection was lost', { code: 'CONNECTION_LOST' }] + ])('reports no refresh status when %s', async (_case, fields) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const refresh = vi.fn(async () => { + throw Object.assign(new Error('relay did not answer'), fields) + }) + + const result = await createWith( + createProvider({ refreshLocalBaseRefForWorktreeCreate: refresh }) + ) + + expect(refresh).toHaveBeenCalledTimes(1) + expect(result).toMatchObject({ worktree: expect.anything() }) + expect(result).not.toHaveProperty('localBaseRefRefresh') + warn.mockRestore() + }) + + it('reports an error once, without retrying, when the relay rejects the refresh', async () => { + const refresh = vi.fn(async () => { + throw new Error('Invalid local base ref refresh refs.') + }) + + const result = await createWith( + createProvider({ refreshLocalBaseRefForWorktreeCreate: refresh }) + ) + + expect(refresh).toHaveBeenCalledTimes(1) + expect(result).toMatchObject({ + localBaseRefRefresh: { status: 'skipped_error', baseRef: 'origin/main', localBranch: 'main' } + }) + }) +}) + +describe('SSH local base refresh overlap', () => { + beforeEach(() => { + setupWorktreeHandlers() + }) + + // #15331: `-b feature-x` proves there was no local feature-x to refresh; probing it would race the relay add. + it('does not refresh or warn when the create makes the local base branch itself', async () => { + const provider = createProvider({ refreshLocalBaseRefForWorktreeCreate: vi.fn() }) + provider.exec.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + // Only the remote-tracking base resolves; refs/heads/feature-x does not exist yet. + const ref = args.at(-1) ?? '' + return { + stdout: args[0] === 'rev-parse' && ref.startsWith('refs/remotes/') ? 'remote-x\n' : '', + stderr: '' + } + }) + provider.listWorktrees + .mockReset() + .mockResolvedValue([ + { path: '/remote/repo-feature-x', head: 'remote-x', branch: 'refs/heads/feature-x' } + ]) + + const result = await createWith(provider, { + repo: { ...REPO, worktreeBaseRef: 'origin/feature-x' }, + name: 'feature-x' + }) + + expect(provider.addWorktree.mock.calls[0]?.[3]).toMatchObject({ base: 'origin/feature-x' }) + expect(provider.addWorktree).toHaveBeenCalledTimes(1) + expect(provider.refreshLocalBaseRefForWorktreeCreate).not.toHaveBeenCalled() + expect(result).not.toHaveProperty('localBaseRefRefresh') + }) + + it('starts the relay worktree add while the refresh is still running', async () => { + let finishRefresh!: () => void + let markRefreshStarted!: () => void + const refreshStarted = new Promise((resolve) => { + markRefreshStarted = resolve + }) + const refresh = vi.fn( + () => + new Promise((resolve) => { + finishRefresh = () => resolve(REFRESH_UPDATED) + markRefreshStarted() + }) + ) + // Would deadlock if create awaited the refresh before starting the add. + const addWorktree = vi.fn(async () => { + await refreshStarted + finishRefresh() + }) + + const result = await createWith( + createProvider({ refreshLocalBaseRefForWorktreeCreate: refresh, addWorktree }) + ) + + expect(addWorktree).toHaveBeenCalledTimes(1) + expect(result).toMatchObject({ localBaseRefRefresh: UPDATED }) + }) + + // The relay runs one refresh per branch host-side, so the app adds no queue of its own. + it('hands every concurrent create straight to the relay', async () => { + const settlers: (() => void)[] = [] + const refresh = vi.fn( + () => new Promise((resolve) => settlers.push(() => resolve(REFRESH_UPDATED))) + ) + const repos = [ + { repo: REPO, name: 'improve-dashboard' }, + { repo: REPO, name: 'fix-login' }, + { repo: { ...REPO, id: 'repo-ssh-other', path: '/remote/other' }, name: 'add-search' } + ] + const provider = createProvider({ refreshLocalBaseRefForWorktreeCreate: refresh }) + provider.listWorktrees.mockResolvedValue( + repos.map(({ repo, name }) => ({ + path: `${repo.path}-${name}`, + head: 'remote-main', + branch: `refs/heads/${name}` + })) + ) + store.getSettings.mockReturnValue({ + branchPrefix: 'none', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: true, + workspaceDir: '/workspace' + }) + store.getRepos.mockReturnValue(repos.map(({ repo }) => repo)) + store.getRepo.mockImplementation((id: string) => repos.find((r) => r.repo.id === id)?.repo) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue({ + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + }) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + + const results = repos.map(({ repo, name }) => + handlers['worktrees:create'](null, { repoId: repo.id, name }) + ) + + await vi.waitFor(() => expect(refresh).toHaveBeenCalledTimes(3)) + settlers.forEach((settle) => settle()) + for (const result of await Promise.all(results)) { + expect(result).toMatchObject({ localBaseRefRefresh: { status: 'updated' } }) + } + }) +}) diff --git a/src/main/ipc/worktrees-ssh-local-base-refresh.test.ts b/src/main/ipc/worktrees-ssh-local-base-refresh.test.ts index c8a4e5088b10..2d8023c9a8eb 100644 --- a/src/main/ipc/worktrees-ssh-local-base-refresh.test.ts +++ b/src/main/ipc/worktrees-ssh-local-base-refresh.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { CreateWorktreeResult } from '../../shared/worktree/create-types' import { getSshGitProviderMock, getActiveMultiplexerMock } from './worktrees-test-module-mocks' import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' @@ -42,27 +41,27 @@ vi.mock('../ssh/ssh-target-registry', async () => vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( - (await importOriginal()) as Record + await importOriginal>() ) ) vi.mock('../worktree-runner-script', async (importOriginal) => (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( - (await importOriginal()) as Record + await importOriginal>() ) ) vi.mock('../effective-hook-config', async (importOriginal) => (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( - (await importOriginal()) as Record + await importOriginal>() ) ) vi.mock('../setup-hook-env-vars', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( - (await importOriginal()) as Record + await importOriginal>() ) ) vi.mock('./worktree-logic', async (importOriginal) => (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( - (await importOriginal()) as Record + await importOriginal>() ) ) vi.mock('../terminal-history-deletion', async () => @@ -85,528 +84,220 @@ vi.mock('../runtime/worktree-teardown', async () => ) vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) -describe('registerWorktreeHandlers', () => { - beforeEach(() => { - setupWorktreeHandlers() - }) +const REPO = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/main' +} +const REFS = { + repoPath: '/remote/repo', + fullRef: 'refs/heads/main', + remoteTrackingRef: 'refs/remotes/origin/main' +} - it('returns SSH local base refresh skip status when the owning worktree is dirty', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' - } - const provider = { - exec: vi.fn().mockImplementation(async (args: string[]) => { - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'show-ref') { - throw Object.assign(new Error('missing remote ref'), { code: 1 }) - } - if (args[0] === 'merge-base') { - return { stdout: '', stderr: '' } - } - if (args[0] === 'log') { - return { stdout: 'commit-a\ncommit-b\ncommit-c\n', stderr: '' } - } - return { stdout: '', stderr: '' } - }), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi - .fn() - .mockResolvedValueOnce([ - { - path: '/remote/repo', - head: 'base123', - branch: 'refs/heads/main', - isBare: false, - isMainWorktree: true - } - ]) - .mockResolvedValueOnce([ - { - path: '/remote/repo-improve-dashboard', - head: 'abc123', - branch: 'refs/heads/improve-dashboard', - isBare: false, - isMainWorktree: false - } - ]), - worktreeIsClean: vi.fn().mockResolvedValue({ clean: false, stdout: ' M package.json\n' }), - refreshLocalBaseRefForWorktreeCreate: vi.fn().mockResolvedValue(undefined) - } - const mux = { - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - } +function createProvider(relay: { + refresh?: ReturnType + behind?: ReturnType +}) { + return { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'show-ref') { + throw Object.assign(new Error('missing remote ref'), { code: 1 }) + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { path: '/remote/repo', head: 'base123', branch: 'refs/heads/main', isMainWorktree: true }, + { + path: '/remote/repo-improve-dashboard', + head: 'abc123', + branch: 'refs/heads/improve-dashboard', + isMainWorktree: false + } + ]), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }), + refreshLocalBaseRefForWorktreeCreate: relay.refresh ?? vi.fn(), + getLocalBaseRefFastForwardableBehind: relay.behind ?? vi.fn() + } +} + +async function createWith( + provider: ReturnType, + options: { refreshSetting?: boolean; worktreeBaseRef?: string; registerRoot?: () => void } = {} +) { + const repo = { ...REPO, worktreeBaseRef: options.worktreeBaseRef ?? REPO.worktreeBaseRef } + if (options.refreshSetting !== false) { store.getSettings.mockReturnValue({ branchPrefix: 'none', nestWorkspaces: false, refreshLocalBaseRefOnWorktreeCreate: true, workspaceDir: '/workspace' }) - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - getSshGitProviderMock.mockReturnValue(provider) - getActiveMultiplexerMock.mockReturnValue(mux) - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue({ + request: vi.fn().mockImplementation(async (method: string) => { + if (method === 'session.registerRoot') { + options.registerRoot?.() + } + }), + notify: vi.fn() + }) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + const result: unknown = await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'improve-dashboard' + }) + return result +} - const result = (await handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'improve-dashboard' - })) as CreateWorktreeResult +/** Refresh git the app used to run itself; the relay now owns all of it on the host. */ +const APP_SIDE_REFRESH_COMMANDS = ['merge-base', 'log', 'rev-list', 'reset', 'update-ref', 'merge'] - expect(provider.exec).toHaveBeenCalledWith( - ['merge-base', '--is-ancestor', 'refs/heads/main', 'refs/remotes/origin/main'], - '/remote/repo' - ) - expect(provider.exec).toHaveBeenCalledWith( - ['log', '--format=%H', 'refs/heads/main..refs/remotes/origin/main'], - '/remote/repo' - ) - expect(provider.worktreeIsClean).toHaveBeenCalledWith('/remote/repo', { - includeUntracked: false - }) - expect(provider.exec).not.toHaveBeenCalledWith( - ['reset', '--hard', 'refs/remotes/origin/main'], - expect.any(String) - ) - expect(provider.refreshLocalBaseRefForWorktreeCreate).not.toHaveBeenCalled() - expect(result).toEqual( - expect.objectContaining({ - localBaseRefRefresh: { - status: 'skipped_dirty_worktree', - baseRef: 'origin/main', - localBranch: 'main', - ownerWorktreePath: '/remote/repo' - } - }) - ) +describe('SSH local base refresh on worktree create', () => { + beforeEach(() => { + setupWorktreeHandlers() }) - it('refreshes SSH local base through the narrow relay RPC when the setting is on', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' - } - const provider = { - exec: vi.fn().mockImplementation(async (args: string[]) => { - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'show-ref') { - throw Object.assign(new Error('missing remote ref'), { code: 1 }) - } - if (args[0] === 'merge-base') { - return { stdout: '', stderr: '' } - } - if (args[0] === 'log') { - return { stdout: 'commit-a\ncommit-b\n', stderr: '' } - } - throw new Error(`unexpected generic exec: ${args.join(' ')}`) - }), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi - .fn() - .mockResolvedValueOnce([ - { - path: '/remote/repo', - head: 'base123', - branch: 'refs/heads/main', - isBare: false, - isMainWorktree: true - } - ]) - .mockResolvedValueOnce([ - { - path: '/remote/repo-improve-dashboard', - head: 'abc123', - branch: 'refs/heads/improve-dashboard', - isBare: false, - isMainWorktree: false - } - ]), - worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }), - refreshLocalBaseRefForWorktreeCreate: vi.fn().mockResolvedValue(undefined) - } - const mux = { - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - } - store.getSettings.mockReturnValue({ - branchPrefix: 'none', - nestWorkspaces: false, - refreshLocalBaseRefOnWorktreeCreate: true, - workspaceDir: '/workspace' - }) - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - getSshGitProviderMock.mockReturnValue(provider) - getActiveMultiplexerMock.mockReturnValue(mux) - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + it('reports the dirty owner the relay found, without inspecting anything itself', async () => { + const refresh = vi + .fn() + .mockResolvedValue({ status: 'skipped_dirty_worktree', ownerWorktreePath: '/remote/repo' }) + const provider = createProvider({ refresh }) - const result = (await handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'improve-dashboard' - })) as CreateWorktreeResult + const result = await createWith(provider) - expect(provider.exec).toHaveBeenCalledWith( - ['merge-base', '--is-ancestor', 'refs/heads/main', 'refs/remotes/origin/main'], - '/remote/repo' - ) - expect(provider.exec).toHaveBeenCalledWith( - ['log', '--format=%H', 'refs/heads/main..refs/remotes/origin/main'], - '/remote/repo' - ) - expect(provider.refreshLocalBaseRefForWorktreeCreate).toHaveBeenCalledWith({ - repoPath: '/remote/repo', - fullRef: 'refs/heads/main', - remoteTrackingRef: 'refs/remotes/origin/main', - ownerWorktreePath: '/remote/repo' + expect(result).toMatchObject({ + localBaseRefRefresh: { + status: 'skipped_dirty_worktree', + baseRef: 'origin/main', + localBranch: 'main', + ownerWorktreePath: '/remote/repo' + } }) - expect(provider.exec).not.toHaveBeenCalledWith( - ['reset', '--hard', 'refs/remotes/origin/main'], - expect.any(String) - ) - expect(provider.exec).not.toHaveBeenCalledWith( - ['update-ref', 'refs/heads/main', 'refs/remotes/origin/main'], - expect.any(String) - ) - expect(result).toEqual( - expect.objectContaining({ - localBaseRefRefresh: { - status: 'updated', - baseRef: 'origin/main', - localBranch: 'main', - ownerWorktreePath: '/remote/repo' - } - }) - ) + const execCommands = provider.exec.mock.calls.map(([args]) => args[0]) + expect(execCommands.filter((c) => APP_SIDE_REFRESH_COMMANDS.includes(c))).toEqual([]) + expect(provider.worktreeIsClean).not.toHaveBeenCalled() }) - it('returns SSH local base update suggestion when a full local base ref is safely behind', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'refs/remotes/origin/main' - } - let registeredRoots = false - const provider = { - exec: vi.fn().mockImplementation(async (args: string[]) => { - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'show-ref') { - throw Object.assign(new Error('missing remote ref'), { code: 1 }) - } - if (args[0] === 'merge-base' || args[0] === 'log') { - if (!registeredRoots) { - throw new Error('Path outside authorized workspace') - } - return { - stdout: args[0] === 'log' ? 'commit-a\ncommit-b\ncommit-c\ncommit-d\n' : '', - stderr: '' - } - } - return { stdout: '', stderr: '' } - }), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockImplementation(async () => { - if (!registeredRoots) { - throw new Error('No workspace roots registered yet') - } - return [ - { - path: '/remote/repo', - head: 'base123', - branch: 'refs/heads/main', - isBare: false, - isMainWorktree: true - }, - { - path: '/remote/repo-improve-dashboard', - head: 'abc123', - branch: 'refs/heads/improve-dashboard', - isBare: false, - isMainWorktree: false - } - ] - }), - worktreeIsClean: vi.fn().mockImplementation(async () => { - if (!registeredRoots) { - throw new Error('Path outside authorized workspace') - } - return { clean: true } - }), - refreshLocalBaseRefForWorktreeCreate: vi.fn().mockResolvedValue(undefined) - } - const mux = { - request: vi.fn().mockImplementation(async (method: string) => { - if (method === 'session.registerRoot') { - registeredRoots = true - } - }), - notify: vi.fn() - } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - getSshGitProviderMock.mockReturnValue(provider) - getActiveMultiplexerMock.mockReturnValue(mux) - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + it('refreshes through the narrow relay RPC with only the refs to move', async () => { + const refresh = vi + .fn() + .mockResolvedValue({ status: 'updated', ownerWorktreePath: '/remote/repo' }) + const provider = createProvider({ refresh }) - const result = await handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'improve-dashboard' - }) + const result = await createWith(provider) - expect(provider.exec).toHaveBeenCalledWith( - ['merge-base', '--is-ancestor', 'refs/heads/main', 'refs/remotes/origin/main'], - '/remote/repo' - ) - expect(provider.exec).toHaveBeenCalledWith( - ['log', '--format=%H', 'refs/heads/main..refs/remotes/origin/main'], - '/remote/repo' - ) - expect(provider.listWorktrees).toHaveBeenCalledWith('/remote/repo') - expect(provider.worktreeIsClean).toHaveBeenCalledWith('/remote/repo', { - includeUntracked: false - }) - expect(provider.refreshLocalBaseRefForWorktreeCreate).toHaveBeenCalledWith({ - repoPath: '/remote/repo', - fullRef: 'refs/heads/main', - remoteTrackingRef: 'refs/remotes/origin/main', - ownerWorktreePath: '/remote/repo', - checkOnly: true + expect(refresh).toHaveBeenCalledTimes(1) + expect(refresh).toHaveBeenCalledWith(REFS) + expect(provider.getLocalBaseRefFastForwardableBehind).not.toHaveBeenCalled() + expect(result).toMatchObject({ + localBaseRefRefresh: { + status: 'updated', + baseRef: 'origin/main', + localBranch: 'main', + ownerWorktreePath: '/remote/repo' + } }) - expect(provider.exec).not.toHaveBeenCalledWith( - ['reset', '--hard', 'refs/remotes/origin/main'], - expect.any(String) - ) - expect(result).toEqual( - expect.objectContaining({ - localBaseRefUpdateSuggestion: { - baseRef: 'origin/main', - localBranch: 'main', - behind: 4 - } - }) - ) }) - it('does not suggest SSH local base updates when the relay cannot refresh local refs', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'refs/remotes/origin/main' - } - const methodNotFound = Object.assign( - new Error('Method not found: git.refreshLocalBaseRefForWorktreeCreate'), - { code: -32601 } - ) - const provider = { - exec: vi.fn().mockImplementation(async (args: string[]) => { - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'show-ref') { - throw Object.assign(new Error('missing remote ref'), { code: 1 }) - } - if (args[0] === 'merge-base') { - return { stdout: '', stderr: '' } - } - if (args[0] === 'log') { - return { stdout: 'commit-a\n', stderr: '' } - } - return { stdout: '', stderr: '' } - }), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi - .fn() - .mockResolvedValueOnce([ - { - path: '/remote/repo', - head: 'base123', - branch: 'refs/heads/main', - isBare: false, - isMainWorktree: true - } - ]) - .mockResolvedValueOnce([ - { - path: '/remote/repo-improve-dashboard', - head: 'abc123', - branch: 'refs/heads/improve-dashboard', - isBare: false, - isMainWorktree: false - } - ]), - worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }), - refreshLocalBaseRefForWorktreeCreate: vi.fn().mockRejectedValue(methodNotFound) - } - const mux = { - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - getSshGitProviderMock.mockReturnValue(provider) - getActiveMultiplexerMock.mockReturnValue(mux) - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + it('reports a branch the relay moved without a checkout as updated with no owner', async () => { + const provider = createProvider({ refresh: vi.fn().mockResolvedValue({ status: 'updated' }) }) - const result = (await handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'improve-dashboard' - })) as CreateWorktreeResult + const result = await createWith(provider) - expect(provider.refreshLocalBaseRefForWorktreeCreate).toHaveBeenCalledWith({ - repoPath: '/remote/repo', - fullRef: 'refs/heads/main', - remoteTrackingRef: 'refs/remotes/origin/main', - ownerWorktreePath: '/remote/repo', - checkOnly: true + expect(result).toMatchObject({ + localBaseRefRefresh: { status: 'updated', baseRef: 'origin/main', localBranch: 'main' } }) - expect(result.localBaseRefUpdateSuggestion).toBeUndefined() + expect(result).not.toHaveProperty('localBaseRefRefresh.ownerWorktreePath') }) - // #15331: the pre-create merge-base probe fails when refs/heads/ does not exist yet. - const buildMissingLocalBaseSshCase = (presence: 'absent' | 'present' | 'probe-failed') => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' - } - const provider = { - exec: vi.fn().mockImplementation(async (args: string[]) => { - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'show-ref' && args.at(-1) === 'refs/heads/main') { - if (presence === 'probe-failed') { - throw new Error('ssh: connection closed by remote host') - } - if (presence === 'present') { - return { stdout: '', stderr: '' } - } - throw Object.assign(new Error('missing ref'), { code: 1 }) - } - if (args[0] === 'show-ref') { - throw Object.assign(new Error('missing ref'), { code: 1 }) - } - if (args[0] === 'rev-parse') { - const ref = args.at(-1) ?? '' - if (ref.startsWith('refs/heads/main')) { - return { stdout: presence === 'present' ? 'local-main\n' : '', stderr: '' } - } - // Other refs/heads probes are the new-branch conflict check; it must stay unresolvable. - return { stdout: ref.startsWith('refs/heads/') ? '' : 'remote-main\n', stderr: '' } - } - if (args[0] === 'merge-base') { - throw new Error('fatal: Not a valid object name refs/heads/main') - } - return { stdout: '', stderr: '' } - }), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '/remote/repo-improve-dashboard', - head: 'abc123', - branch: 'refs/heads/improve-dashboard', - isBare: false, - isMainWorktree: false - } - ]), - worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }), - refreshLocalBaseRefForWorktreeCreate: vi.fn().mockResolvedValue(undefined) - } - store.getSettings.mockReturnValue({ - branchPrefix: 'none', - nestWorkspaces: false, - refreshLocalBaseRefOnWorktreeCreate: true, - workspaceDir: '/workspace' - }) - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - getSshGitProviderMock.mockReturnValue(provider) - getActiveMultiplexerMock.mockReturnValue({ - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - }) - store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) - return provider - } - it('does not report an SSH local base refresh when the local base branch does not exist', async () => { - const provider = buildMissingLocalBaseSshCase('absent') + // #15331: the relay proves the local branch absent (or current); there is nothing to report. + it('reports no refresh status when the relay had nothing to do', async () => { + const provider = createProvider({ + refresh: vi.fn().mockResolvedValue({ status: 'nothing_to_do' }) + }) - const result = (await handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'improve-dashboard' - })) as CreateWorktreeResult + const result = await createWith(provider) - expect(provider.exec).toHaveBeenCalledWith( - ['show-ref', '--verify', '--quiet', '--', 'refs/heads/main'], - '/remote/repo' - ) - expect(result.localBaseRefRefresh).toBeUndefined() - expect(provider.refreshLocalBaseRefForWorktreeCreate).not.toHaveBeenCalled() + expect(provider.refreshLocalBaseRefForWorktreeCreate).toHaveBeenCalledTimes(1) + expect(result).not.toHaveProperty('localBaseRefRefresh') }) - it('keeps the SSH not-fast-forward status when the local base branch exists and diverged', async () => { - const provider = buildMissingLocalBaseSshCase('present') + it('keeps the not-fast-forward status the relay reports', async () => { + const provider = createProvider({ + refresh: vi.fn().mockResolvedValue({ status: 'skipped_not_fast_forward' }) + }) - const result = (await handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'improve-dashboard' - })) as CreateWorktreeResult + const result = await createWith(provider) - expect(result.localBaseRefRefresh).toEqual({ - status: 'skipped_not_fast_forward', - baseRef: 'origin/main', - localBranch: 'main' + expect(result).toMatchObject({ + localBaseRefRefresh: { + status: 'skipped_not_fast_forward', + baseRef: 'origin/main', + localBranch: 'main' + } }) - expect(provider.refreshLocalBaseRefForWorktreeCreate).not.toHaveBeenCalled() }) +}) - // Losing the relay mid-probe is not evidence the branch is missing. - it('keeps the SSH not-fast-forward status when the local base ref probe fails', async () => { - const provider = buildMissingLocalBaseSshCase('probe-failed') +describe('SSH local base update suggestion on worktree create', () => { + beforeEach(() => { + setupWorktreeHandlers() + }) - const result = (await handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'improve-dashboard' - })) as CreateWorktreeResult + it('suggests an update from the relay inspection once the workspace root is registered', async () => { + let registeredRoots = false + const behind = vi.fn().mockImplementation(async () => { + if (!registeredRoots) { + throw new Error('Path outside authorized workspace') + } + return 4 + }) + const provider = createProvider({ behind }) - expect(result.localBaseRefRefresh).toEqual({ - status: 'skipped_not_fast_forward', - baseRef: 'origin/main', - localBranch: 'main' + const result = await createWith(provider, { + refreshSetting: false, + worktreeBaseRef: 'refs/remotes/origin/main', + registerRoot: () => (registeredRoots = true) }) + + expect(behind).toHaveBeenCalledWith(REFS) expect(provider.refreshLocalBaseRefForWorktreeCreate).not.toHaveBeenCalled() + expect(result).toMatchObject({ + localBaseRefUpdateSuggestion: { baseRef: 'origin/main', localBranch: 'main', behind: 4 } + }) + }) + + it.each([ + ['cannot be fast-forwarded', async () => undefined], + [ + 'cannot be inspected', + async () => { + throw Object.assign(new Error('Method not found'), { code: -32601 }) + } + ] + ])('does not suggest an update when the local base %s', async (_case, inspect) => { + const behind = vi.fn(inspect) + const provider = createProvider({ behind }) + + const result = await createWith(provider, { + refreshSetting: false, + worktreeBaseRef: 'refs/remotes/origin/main' + }) + + expect(behind).toHaveBeenCalledTimes(1) + expect(result).not.toHaveProperty('localBaseRefUpdateSuggestion') }) }) diff --git a/src/main/ipc/worktrees-test-harness.ts b/src/main/ipc/worktrees-test-harness.ts index 6e1019d2150f..bbe2a1491610 100644 --- a/src/main/ipc/worktrees-test-harness.ts +++ b/src/main/ipc/worktrees-test-harness.ts @@ -306,7 +306,7 @@ export function setupWorktreeHandlers(): WorktreeRuntimeStub { // Default: no direct-read recovery, so a listing that omits the row still fails the create. describeCreatedWorktreeMock.mockResolvedValue(undefined) forceDeleteLocalBranchMock.mockResolvedValue(undefined) - const runtimeStub = createWorktreeRuntimeStub() + const runtimeStub = createWorktreeRuntimeStub(mainWindow) registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never) return runtimeStub } diff --git a/src/main/ipc/worktrees-test-runtime-stub.ts b/src/main/ipc/worktrees-test-runtime-stub.ts index bb2f33cb05e5..68b49754f4a4 100644 --- a/src/main/ipc/worktrees-test-runtime-stub.ts +++ b/src/main/ipc/worktrees-test-runtime-stub.ts @@ -1,3 +1,4 @@ +import { _resetPendingWorktreeRemovalsForTests } from '../worktree-background-removal' import { vi } from 'vitest' export type WorktreeRuntimeStub = { @@ -18,10 +19,15 @@ export type WorktreeRuntimeStub = { closeFileWatchersForRemoval: ReturnType acquireFileWatcherRemoval: ReturnType hydrateInferredWorktreeLineage: ReturnType + publishWorktreeRemovalChange: ReturnType } /** Why: create-flow tests need a minimal runtime; full fetchRemoteWithCache behavior lives in fetch-remote-cache.test.ts. */ -export function createWorktreeRuntimeStub(): WorktreeRuntimeStub { +export function createWorktreeRuntimeStub(mainWindow?: { + webContents: { send: (channel: string, ...args: unknown[]) => unknown } +}): WorktreeRuntimeStub { + // Why here: every harness setup builds a stub, so no removal from an earlier test leaks in. + _resetPendingWorktreeRemovalsForTests() const runtimeStub: WorktreeRuntimeStub = { resolveRemoteTrackingBase: vi.fn().mockResolvedValue(null), hasRemoteTrackingRef: vi.fn().mockResolvedValue(false), @@ -48,7 +54,11 @@ export function createWorktreeRuntimeStub(): WorktreeRuntimeStub { notifyWorktreesChangedForRemoteClients: vi.fn(), closeFileWatchersForRemoval: vi.fn().mockResolvedValue(undefined), acquireFileWatcherRemoval: vi.fn(), - hydrateInferredWorktreeLineage: vi.fn().mockResolvedValue(undefined) + hydrateInferredWorktreeLineage: vi.fn().mockResolvedValue(undefined), + // Mirrors the real runtime notifier, which sends worktrees:changed to the window. + publishWorktreeRemovalChange: vi.fn((repoId: string) => { + mainWindow?.webContents.send('worktrees:changed', { repoId }) + }) } runtimeStub.acquireFileWatcherRemoval.mockImplementation( async (worktreePath: string, connectionId?: string) => { diff --git a/src/main/ipc/worktrees-windows.test.ts b/src/main/ipc/worktrees-windows.test.ts index fb69743c288e..596ff07be52d 100644 --- a/src/main/ipc/worktrees-windows.test.ts +++ b/src/main/ipc/worktrees-windows.test.ts @@ -311,7 +311,10 @@ describe('registerWorktreeHandlers – Windows path handling', () => { closeFileWatchersForRemoval: vi.fn().mockResolvedValue(undefined), acquireFileWatcherRemoval: vi.fn().mockResolvedValue({ finish: vi.fn().mockResolvedValue(undefined) - }) + }), + publishWorktreeRemovalChange: vi.fn((repoId: string) => + mainWindow.webContents.send('worktrees:changed', { repoId }) + ) } registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never) }) diff --git a/src/main/ipc/worktrees/listing/detected-provider-listing.ts b/src/main/ipc/worktrees/listing/detected-provider-listing.ts index 975cb5eeb783..8bf5d924b2ce 100644 --- a/src/main/ipc/worktrees/listing/detected-provider-listing.ts +++ b/src/main/ipc/worktrees/listing/detected-provider-listing.ts @@ -1,3 +1,7 @@ +import { + projectPendingWorktreeRemovals, + snapshotPendingWorktreeRemovals +} from '../../../worktree-background-removal' import { getRepoExecutionHostId, getSshTargetIdForExecutionHost @@ -128,6 +132,7 @@ export async function listDetectedWorktreesForCapturedRepo( catalogVersion: getLocalWorktreeCatalogVersion(repo.id) } } + const pendingAtScan = snapshotPendingWorktreeRemovals() const scan = await scanUntilNotOvertaken( repo.id, connectionId && provider @@ -184,7 +189,15 @@ export async function listDetectedWorktreesForCapturedRepo( authoritative: true, source: 'git', catalogVersion: localWorktreeCatalogVersionAt(scan.generation), - worktrees: buildDetectedGitWorktrees(store, repo, gitWorktrees, allMeta) + worktrees: connectionId + ? buildDetectedGitWorktrees(store, repo, gitWorktrees, allMeta) + : // Why always marked: the desktop renderer ships with this main process. + projectPendingWorktreeRemovals( + buildDetectedGitWorktrees(store, repo, gitWorktrees, allMeta), + (worktree) => worktree.id, + true, + pendingAtScan + ) } } catch (err) { const aborted = abortedResult() diff --git a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts index 89de29f30e84..c09c1224e88a 100644 --- a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts @@ -32,11 +32,25 @@ import { readAllWorktreeMetaForRepo } from '../../../persistence/host-qualified-worktree-meta' import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { + projectPendingWorktreeRemovals, + snapshotPendingWorktreeRemovals, + type PendingWorktreeRemovals +} from '../../../worktree-background-removal' import { getLocalWorktreeScanGeneration } from '../../../local-worktree-scan-generation' import { getRegisteredWorktreeRootsRevision } from '../../registered-worktree-roots-cache' const WORKTREE_LIST_ALL_CONCURRENCY = 8 +// Why always marked: the desktop renderer ships with this main process, so it reads the marker. +function markLocalWorktreesUnderRemoval( + worktrees: T[], + pendingAtScan: PendingWorktreeRemovals +): T[] { + return projectPendingWorktreeRemovals(worktrees, (worktree) => worktree.id, true, pendingAtScan) +} + async function mapWithConcurrency( items: readonly T[], limit: number, @@ -102,6 +116,7 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo let sideEffectToken: DetectedWorktreeSideEffectToken | undefined let metadataPrune: DetectedWorktreeMetadataPrune | undefined let hygieneDue: boolean | undefined + const pendingAtScan = snapshotPendingWorktreeRemovals() if (isFolderRepo(repo)) { return listVisibleFolderWorkspaces(store, repo) } else if (connectionId) { @@ -152,9 +167,10 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo } loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) const metadata = metadataForRepo(repo) - return buildDetectedGitWorktrees(store, repo, gitWorktrees, metadata) + const worktrees = buildDetectedGitWorktrees(store, repo, gitWorktrees, metadata) .filter((worktree) => worktree.visible) .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree, metadata)) + return connectionId ? worktrees : markLocalWorktreesUnderRemoval(worktrees, pendingAtScan) } catch (err) { warnOnce( loggedWorktreeListFailures, @@ -200,6 +216,7 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo let sideEffectToken: DetectedWorktreeSideEffectToken | undefined let metadataPrune: DetectedWorktreeMetadataPrune | undefined let hygieneDue: boolean | undefined + const pendingAtScan = snapshotPendingWorktreeRemovals() if (isFolderRepo(repo)) { return listVisibleFolderWorkspaces(store, repo) } else if (connectionId) { @@ -244,9 +261,10 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo } loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) const metadata = allMeta ?? readAllWorktreeMetaForRepo(store, repo) - return buildDetectedGitWorktrees(store, repo, gitWorktrees, metadata) + const worktrees = buildDetectedGitWorktrees(store, repo, gitWorktrees, metadata) .filter((worktree) => worktree.visible) .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree, metadata)) + return connectionId ? worktrees : markLocalWorktreesUnderRemoval(worktrees, pendingAtScan) } catch (err) { warnOnce( loggedWorktreeListFailures, diff --git a/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts index edcc0899f730..d91743b01850 100644 --- a/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts +++ b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts @@ -12,6 +12,11 @@ import { getWorktreeRemovalOptionsKey } from './worktree-removal-coordinator' import { resolveRepoForExecutionHost } from '../repo-host-ownership' +import { + finishAcceptedWorktreeRemoval, + waitForPendingWorktreeRemoval +} from '../../../worktree-background-removal' +import { runSerializedWorktreeRemovalAcceptance } from '../../../worktree-removal-acceptance-queue' export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): void { const { store, options, worktreeRemovalsInFlight } = context @@ -26,6 +31,11 @@ export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): vo } // The resolved repo supplies host ownership when legacy callers omit args.hostId. const removalHostId = getRepoExecutionHostId(repo) + // Why: a retry or a second window asking while Git still deletes joins that removal. + const pending = waitForPendingWorktreeRemoval(args.worktreeId, removalHostId) + if (pending) { + return { ...(await pending), catalogVersion: getLocalWorktreeCatalogVersion(repoId) } + } const inFlightKey = getWorktreeRemovalInFlightKey(args.worktreeId, removalHostId) const optionsKey = getWorktreeRemovalOptionsKey(args) const inFlightRemoval = worktreeRemovalsInFlight.get(inFlightKey) @@ -38,27 +48,30 @@ export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): vo // Why: concurrent stale-toast/double-click/sidebar races can hit the same worktree; share the op so only one path touches Git and disk. const removal = withWorktreeSpan({ stage: 'remove', path: worktreePath }, async () => { - const result = await executeWorktreeRemoval( - context, - args, - repo, - repoId, - worktreePath, - removalHostId - ) + const accept = async (): Promise => + // Why: another client's removal of this worktree may have been accepted during the wait. + waitForPendingWorktreeRemoval(args.worktreeId, removalHostId) + ? { removing: true } + : executeWorktreeRemoval(context, args, repo, repoId, worktreePath, removalHostId) + const accepted = await (repo.connectionId + ? accept() + : runSerializedWorktreeRemovalAcceptance(repo.path, accept)) + const result = await finishAcceptedWorktreeRemoval(accepted, args.worktreeId, removalHostId) + // A background job reports its own lifecycle when Git finishes. + if (!accepted.removing) { + options?.onWorktreeLifecycle?.({ + kind: 'removed', + worktreeId: args.worktreeId, + path: worktreePath + }) + } // Why stamped inside the shared promise: a coalesced second caller gets the same reply, // naming the catalog this removal produced. return { ...result, catalogVersion: getLocalWorktreeCatalogVersion(repoId) } }) worktreeRemovalsInFlight.set(inFlightKey, { optionsKey, promise: removal }) try { - const result = await removal - options?.onWorktreeLifecycle?.({ - kind: 'removed', - worktreeId: args.worktreeId, - path: parseWorktreeId(args.worktreeId).worktreePath - }) - return result + return await removal } finally { if (worktreeRemovalsInFlight.get(inFlightKey)?.promise === removal) { worktreeRemovalsInFlight.delete(inFlightKey) diff --git a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts index 5a36199c8e3d..a75a2607c9e5 100644 --- a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts +++ b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts @@ -1,7 +1,7 @@ import type { Repo } from '../../../../shared/repo-types' import type { ExecutionHostId } from '../../../../shared/execution-host' import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' -import type { GitPushTarget } from '../../../../shared/worktree/types' +import type { GitPushTarget, GitWorktreeInfo } from '../../../../shared/worktree/types' import { assertWorktreeUnlockedForRemoval } from '../../../../shared/worktree/removal' import type { LocalProjectWorktreeGitOptions } from '../../../project-runtime-git-options' import { @@ -16,10 +16,7 @@ import { recoverLocalWindowsWorktreeRemoval } from '../../../local-worktree-remo import { withWorktreeRemoveStageSpan } from '../../../observability/instrumentation' import { findRegisteredDeletableWorktree } from '../../../worktree-removal-safety' import { CLIENT_REMOVAL_HOME } from '../../../worktree-removal-home-guard' -import { - cleanupUnusedWorktreePushTargetRemote, - notifyWorktreesChanged -} from '../../worktree-remote' +import { cleanupUnusedWorktreePushTargetRemote } from '../../worktree-remote' import { findExistingWorktreeSymlinkPaths, removeWorktreeLinkedPaths @@ -43,6 +40,10 @@ import { stopPtysForDestructiveWorktreeRemoval } from './worktree-removal-ownership' import { preservedBranchCleanupScopeKey } from '../../../../shared/preserved-branch-cleanup' +import { + removesInBackground, + startBackgroundWorktreeRemoval +} from '../../../worktree-background-removal' export async function removeRegisteredLocalWorktree( context: WorktreeIpcContext, @@ -56,7 +57,7 @@ export async function removeRegisteredLocalWorktree( hasLocalWorktreeGitOptions: boolean, deleteBranch: boolean ): Promise { - const { mainWindow, store, runtime } = context + const { runtime } = context const refreshedWorktrees = hasLocalWorktreeGitOptions ? await listGitWorktreesStrict(repo.path, localWorktreeGitOptions) : await listGitWorktreesStrict(repo.path) @@ -103,11 +104,10 @@ export async function removeRegisteredLocalWorktree( // Why: Git can still classify this as an orphan after preflight; keep strict PTY teardown before any recursive fallback deletion. } - let removalResult: RemoveWorktreeResult | undefined const removalGate = await withWorktreeRemoveStageSpan('watcher_gate', 'local', async () => runtime.acquireFileWatcherRemoval(canonicalWorktreePath) ) - let removalCompleted = false + let accepted = false try { // Why: hold the watcher/terminal gate through Git and any recursive fallback so no late spawn recreates a native handle. // Linked-path deletion is destructive too, so PTYs must release every handle before Windows or WSL filesystem cleanup starts. @@ -121,13 +121,99 @@ export async function removeRegisteredLocalWorktree( if (linkedPaths.length > 0) { await removeWorktreeLinkedPaths(canonicalWorktreePath, linkedPaths) } + accepted = true + } finally { + if (!accepted) { + await removalGate.finish(false) + } + } + const finish = (checkoutDeleteSignal?: AbortSignal): Promise => + finishLocalWorktreeRemoval({ + context, + args, + repo, + repoId, + canonicalWorktreePath, + removalHostId, + removedPushTarget, + localWorktreeGitOptions, + hasLocalWorktreeGitOptions, + deleteBranch, + refreshedRegisteredWorktree, + removalGate, + checkoutDeleteSignal + }) + if (!removesInBackground(canonicalWorktreePath, localWorktreeGitOptions)) { + const result = await finish() + runtime.publishWorktreeRemovalChange(repoId) + return result + } + // Why detached: every refusal above already ran, and Git's 20-35 s delete must finish even when the + // request that asked for it goes away; other views read the host's `removing` marker meanwhile. + void startBackgroundWorktreeRemoval({ + removal: { + worktreeId: args.worktreeId, + repoId, + repoPath: repo.path, + worktree: refreshedRegisteredWorktree, + deleteBranch, + force: args.force ?? false + }, + run: async (stopSignal) => { + const result = await finish(stopSignal) + context.options?.onWorktreeLifecycle?.({ + kind: 'removed', + worktreeId: args.worktreeId, + path: canonicalWorktreePath + }) + return result + }, + publish: () => runtime.publishWorktreeRemovalChange(repoId) + }) + return { removing: true } +} + +async function finishLocalWorktreeRemoval({ + context, + args, + repo, + repoId, + canonicalWorktreePath, + removalHostId, + removedPushTarget, + localWorktreeGitOptions, + hasLocalWorktreeGitOptions, + deleteBranch, + refreshedRegisteredWorktree, + removalGate, + checkoutDeleteSignal +}: { + context: WorktreeIpcContext + args: RemoveWorktreeArgs + repo: Repo + repoId: string + canonicalWorktreePath: string + removalHostId: ExecutionHostId + removedPushTarget: GitPushTarget | undefined + localWorktreeGitOptions: LocalProjectWorktreeGitOptions + hasLocalWorktreeGitOptions: boolean + deleteBranch: boolean + refreshedRegisteredWorktree: GitWorktreeInfo + removalGate: { finish: (removed: boolean) => Promise } + checkoutDeleteSignal?: AbortSignal +}): Promise { + const { store, runtime } = context + let removalResult: RemoveWorktreeResult | undefined + let removalCompleted = false + try { try { const removeOptions = { ...(!deleteBranch ? { deleteBranch } : {}), // Why: reuse the authoritative worktree list already computed here instead of rescanning siblings on the hot delete path. knownRemovedWorktree: refreshedRegisteredWorktree, - ...(hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {}) + ...(hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {}), + checkoutDeleteSignal } removalResult = preserveBranchHeadFallback( await withWorktreeRemoveStageSpan('git_remove', 'local', async () => @@ -187,7 +273,6 @@ export async function removeRegisteredLocalWorktree( }) ) invalidateAuthorizedRootsCache() - notifyWorktreesChanged(mainWindow, repoId) removalCompleted = true return {} } else { @@ -228,7 +313,5 @@ export async function removeRegisteredLocalWorktree( await withWorktreeRemoveStageSpan('cache_invalidation', 'local', async () => { invalidateAuthorizedRootsCache() }) - - notifyWorktreesChanged(mainWindow, repoId) return removalResult ?? {} } diff --git a/src/main/native-chat/agent-session-journal/journal-database-records-migration.test.ts b/src/main/native-chat/agent-session-journal/journal-database-records-migration.test.ts new file mode 100644 index 000000000000..120588b306dd --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-database-records-migration.test.ts @@ -0,0 +1,227 @@ +// Version 4: the chat records join the journal database, copied in by the migration's own +// transaction, so "copied" is exactly `user_version >= 4`. + +import { existsSync } from 'node:fs' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import Database from '../../sqlite/sync-database' +import { + journalPragmaNumber, + NO_LEGACY_JOURNAL_RECORDS, + openJournalDatabase, + readJournalDatabaseVersion, + type JournalLegacyRecordImport +} from './journal-database' +import { createJournalTablesSql } from './journal-database-schema' +import { journalDatabasePath } from './journal-host-database' + +let root: string +let dbPath: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-records-migration-')) + dbPath = journalDatabasePath(root) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +/** What a build from before the records moved left: the released version-3 shape. */ +function seedVersion3(): void { + const db = new Database(dbPath) + db.pragma('journal_mode = WAL') + db.exec(createJournalTablesSql()) + db.prepare( + "INSERT INTO journal_sessions (session_id, workspace_id, epoch) VALUES ('s1', 'ws', 'e1')" + ).run() + db.pragma('user_version = 3') + db.close() +} + +/** A copy of one record row, counting how many times the migration ran it. */ +function importOf(sessionId: string): JournalLegacyRecordImport & { runs: number } { + const copy = { + owed: false as const, + runs: 0, + write: (db: Database.Database) => { + copy.runs += 1 + db.prepare( + 'INSERT OR IGNORE INTO agent_session_records (session_id, record_json) VALUES (?, ?)' + ).run(sessionId, '{}') + } + } + return copy +} + +function inspect(read: (db: Database.Database) => T): T { + const db = new Database(dbPath) + try { + return read(db) + } finally { + db.close() + } +} + +const recordIds = (db: Database.Database): string[] => + db + .prepare('SELECT session_id FROM agent_session_records ORDER BY session_id') + .all() + .map((row) => String(row.session_id)) + +const hasTable = (db: Database.Database, name: string): boolean => + db + .prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(name) !== undefined + +describe('the version-4 migration', () => { + it('moves a version-3 database to 4 with its rows and the copied records', () => { + seedVersion3() + const copy = importOf('session-a') + + const opened = openJournalDatabase(dbPath, copy) + opened.db.close() + + expect(opened).toMatchObject({ readOnly: false, legacyRecordImportOwed: false }) + expect(copy.runs).toBe(1) + inspect((db) => { + expect(journalPragmaNumber(db, 'user_version')).toBe(4) + expect(recordIds(db)).toEqual(['session-a']) + expect(db.prepare('SELECT epoch FROM journal_sessions').get()).toEqual({ epoch: 'e1' }) + }) + }) + + it('never runs the copy again once the database is at 4', () => { + seedVersion3() + openJournalDatabase(dbPath, importOf('session-a')).db.close() + const again = importOf('session-b') + + openJournalDatabase(dbPath, again).db.close() + + expect(again.runs).toBe(0) + inspect((db) => expect(recordIds(db)).toEqual(['session-a'])) + }) + + it('leaves version 3 and no record rows when it dies inside the migration, then copies once', () => { + seedVersion3() + const original = Database.prototype.pragma + const pragma = vi.spyOn(Database.prototype, 'pragma').mockImplementation(function ( + this: Database.Database, + sql: string, + options?: { simple?: boolean } + ) { + if (sql === 'user_version = 4') { + throw new Error('crash before the version is published') + } + return original.call(this, sql, options) + }) + + expect(() => openJournalDatabase(dbPath, importOf('session-a'))).toThrow( + 'crash before the version is published' + ) + pragma.mockRestore() + inspect((db) => { + expect(journalPragmaNumber(db, 'user_version')).toBe(3) + expect(hasTable(db, 'agent_session_records')).toBe(false) + }) + + const retry = importOf('session-a') + openJournalDatabase(dbPath, retry).db.close() + expect(retry.runs).toBe(1) + inspect((db) => { + expect(journalPragmaNumber(db, 'user_version')).toBe(4) + expect(recordIds(db)).toEqual(['session-a']) + }) + }) + + it('makes the tables but keeps the copy owed when the records file could not be read', () => { + seedVersion3() + + const opened = openJournalDatabase(dbPath, { owed: true }) + // A chat created while the copy is owed keeps its row through the copy that follows. + opened.db + .prepare("INSERT INTO agent_session_records (session_id, record_json) VALUES ('new', '{}')") + .run() + opened.db.close() + + expect(opened.legacyRecordImportOwed).toBe(true) + inspect((db) => expect(journalPragmaNumber(db, 'user_version')).toBe(3)) + openJournalDatabase(dbPath, importOf('session-a')).db.close() + inspect((db) => { + expect(journalPragmaNumber(db, 'user_version')).toBe(4) + expect(recordIds(db)).toEqual(['new', 'session-a']) + }) + }) + + it('stamps a fresh file with the released version while the copy is owed', () => { + openJournalDatabase(dbPath, { owed: true }).db.close() + + inspect((db) => { + expect(journalPragmaNumber(db, 'user_version')).toBe(3) + expect(hasTable(db, 'journal_rows')).toBe(true) + expect(hasTable(db, 'agent_session_records')).toBe(true) + }) + }) + + // An install's first probe must not leave an empty database that reads as a profile with chats. + it('reads a missing database as version 0 without creating it', () => { + expect(readJournalDatabaseVersion(dbPath)).toBe(0) + expect(existsSync(dbPath)).toBe(false) + }) + + it('creates every table on a fresh file at version 4', () => { + openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db.close() + + inspect((db) => { + expect(journalPragmaNumber(db, 'user_version')).toBe(4) + for (const table of [ + 'journal_rows', + 'agent_session_records', + 'agent_session_operations', + 'agent_session_retired_claim_keys', + 'agent_session_tabs', + 'agent_session_store_meta' + ]) { + expect(hasTable(db, table)).toBe(true) + } + }) + }) +}) + +/** + * The open of a build released before the records moved (version 3), pinned here: it latches any + * higher version read-only. Its records file stays its own and writable, so nothing it does can + * reach the records this database holds. + */ +function openAsVersion3Build(path: string): { db: Database.Database; readOnly: boolean } { + const probe = new Database(path) + const stored = journalPragmaNumber(probe, 'user_version') + if (stored > 3) { + probe.close() + return { db: new Database(path, { readonly: true, fileMustExist: true }), readOnly: true } + } + return { db: probe, readOnly: false } +} + +describe('a build from before the move', () => { + it('opens a version-4 database read-only, so it never writes beside the records', () => { + openJournalDatabase(dbPath, importOf('session-a')).db.close() + + const older = openAsVersion3Build(dbPath) + try { + expect(older.readOnly).toBe(true) + expect(() => + older.db + .prepare( + "INSERT INTO journal_sessions (session_id, workspace_id, epoch) VALUES ('s2', 'ws', 'e')" + ) + .run() + ).toThrow(/readonly/i) + } finally { + older.db.close() + } + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-database-schema.ts b/src/main/native-chat/agent-session-journal/journal-database-schema.ts index ffad529cb4b4..e110b9221c44 100644 --- a/src/main/native-chat/agent-session-journal/journal-database-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-database-schema.ts @@ -6,12 +6,16 @@ // for a rebuild a partial repair leaves behind (see journal-repair-marker.ts). `journal_imports` // records which per-chat file each chat was copied from (journal-per-session-reimport.ts), and // `journal_set_aside` each chat whose per-chat file is not this build's history and is never read -// again. +// again. The `agent_session_*` tables hold each chat's ownership record, its operation ledger, the +// retired claim keys and the chat tab index (agent-session-record-rows.ts). /** DB shape version, carried in `PRAGMA user_version`. Independent of the row body version * (`JournalRow.v`): a newer build can change either alone. A newer version opens read-only here, - * so every change stays additive. Versions below 3 were never released (journal-database.ts). */ -export const JOURNAL_DB_SCHEMA_VERSION = 3 + * so every change stays additive. */ +export const JOURNAL_DB_SCHEMA_VERSION = 4 + +/** The first version a release wrote; 1 and 2 only ever came from development builds. */ +export const JOURNAL_DB_OLDEST_RELEASED_VERSION = 3 export function createJournalTablesSql(): string { return ` @@ -46,3 +50,31 @@ CREATE TABLE IF NOT EXISTS journal_set_aside ( ); ` } + +/** Version 4: the chat records, until then a JSON file beside the database. Each row is one JSON + * value as the file held it, so a row this build cannot read stays byte-identical. */ +export function createAgentSessionRecordTablesSql(): string { + return ` +CREATE TABLE IF NOT EXISTS agent_session_records ( + session_id TEXT PRIMARY KEY, + record_json TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS agent_session_operations ( + operation_key TEXT PRIMARY KEY, + row_json TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS agent_session_retired_claim_keys ( + key_id TEXT PRIMARY KEY, + retired_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS agent_session_tabs ( + tab_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + position INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS agent_session_store_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +` +} diff --git a/src/main/native-chat/agent-session-journal/journal-database.test.ts b/src/main/native-chat/agent-session-journal/journal-database.test.ts index 0b479cd9adf6..677efc21c4d7 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.test.ts @@ -8,6 +8,7 @@ import { JOURNAL_BUSY_TIMEOUT_MS, JOURNAL_SIZE_LIMIT_BYTES, journalPragmaNumber, + NO_LEGACY_JOURNAL_RECORDS, openJournalDatabase } from './journal-database' import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' @@ -66,7 +67,7 @@ afterEach(async () => { describe('the host journal database open', () => { it('creates every table and reads back every load-bearing pragma', () => { - const db = openJournalDatabase(dbPath).db + const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db try { const tables = db .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") @@ -97,14 +98,14 @@ describe('the host journal database open', () => { // T5: a newer build's database opens read-only, its rows readable, and is left byte-identical. it('opens a newer user_version read-only without touching the file', async () => { - const seeded = openJournalDatabase(dbPath).db + const seeded = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db publishJournalSessionEpoch(seeded, SESSION, 'epoch-1') insertJournalRow(seeded, 'session-1', epochRow(1)) seeded.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 5}`) seeded.close() const before = await digest(dbPath) - const opened = openJournalDatabase(dbPath) + const opened = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS) try { expect(opened.readOnly).toBe(true) expect(readJournalSessionEpoch(opened.db, 'session-1')).toBe('epoch-1') @@ -121,7 +122,7 @@ describe('the host journal database open', () => { it('closes the raw connection when schema setup throws', async () => { const failing = join(root, 'nested', 'agent-session-journal.db') - expect(() => openJournalDatabase(failing)).toThrow() + expect(() => openJournalDatabase(failing, NO_LEGACY_JOURNAL_RECORDS)).toThrow() await expect(stat(`${failing}-wal`)).rejects.toThrow() await expect(rm(root, { recursive: true, force: true })).resolves.toBeUndefined() root = await mkdtemp(join(tmpdir(), 'orca-journal-db-')) @@ -130,7 +131,7 @@ describe('the host journal database open', () => { describe('journal row statements', () => { it('serves replay, resume, suffix truncation and an epoch discard', () => { - const db = openJournalDatabase(dbPath).db + const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db try { db.exec('BEGIN IMMEDIATE') for (let seq = 1; seq <= 5; seq += 1) { @@ -161,7 +162,7 @@ describe('journal row statements', () => { }) it('deletes only the rows no pointer names', () => { - const db = openJournalDatabase(dbPath).db + const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db try { insertJournalRow(db, 'session-1', epochRow(1, 'epoch-copying')) insertJournalRow(db, 'session-2', epochRow(1, 'epoch-live')) @@ -180,7 +181,7 @@ describe('journal row statements', () => { }) it('refuses a duplicate sequence inside one epoch of one chat', () => { - const db = openJournalDatabase(dbPath).db + const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db try { insertJournalRow(db, 'session-1', epochRow(1)) expect(() => insertJournalRow(db, 'session-1', epochRow(1))).toThrow() @@ -192,7 +193,7 @@ describe('journal row statements', () => { }) it('moves the epoch pointer in place', () => { - const db = openJournalDatabase(dbPath).db + const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db try { publishJournalSessionEpoch(db, SESSION, 'epoch-1') publishJournalSessionEpoch(db, SESSION, 'epoch-2') @@ -220,7 +221,9 @@ describe('schema creation', () => { return original.call(this, sql, options) }) - expect(() => openJournalDatabase(dbPath)).toThrow('crash before the version is published') + expect(() => openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS)).toThrow( + 'crash before the version is published' + ) pragma.mockRestore() const inspected = new Database(dbPath) @@ -250,8 +253,12 @@ INSERT INTO journal_sessions VALUES ('s1', 'ws', 'e1', 0, NULL, NULL);`) earlier.close() const before = await digest(dbPath) - expect(() => openJournalDatabase(dbPath)).toThrow(`unreleased schema ${version}`) - expect(() => openJournalDatabase(dbPath)).toThrow(JournalUnreleasedSchemaError) + expect(() => openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS)).toThrow( + `unreleased schema ${version}` + ) + expect(() => openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS)).toThrow( + JournalUnreleasedSchemaError + ) expect(await digest(dbPath)).toBe(before) await expect(stat(`${dbPath}-wal`)).rejects.toThrow() diff --git a/src/main/native-chat/agent-session-journal/journal-database.ts b/src/main/native-chat/agent-session-journal/journal-database.ts index 2c01a653af38..a306ad6e564e 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.ts @@ -4,9 +4,15 @@ // no DDL: a database written by a newer schema must be left byte-identical, and // `journal_mode = WAL` writes the file header. +import { existsSync } from 'node:fs' import Database from '../../sqlite/sync-database' import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files' -import { createJournalTablesSql, JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' +import { + createAgentSessionRecordTablesSql, + createJournalTablesSql, + JOURNAL_DB_OLDEST_RELEASED_VERSION, + JOURNAL_DB_SCHEMA_VERSION +} from './journal-database-schema' import { JournalUnreleasedSchemaError } from './journal-open-failure' import { ensureQueuedMessagesTable } from './queued-message-schema' @@ -20,13 +26,80 @@ export type OpenJournalDatabase = { db: Database.Database /** A newer `user_version` was met: this build reads and never writes. */ readOnly: boolean + /** The chat records file could not be read this launch: version 4's copy of it is still owed. */ + legacyRecordImportOwed: boolean +} + +/** + * What version 4's migration copies in from the chat records file that preceded it, read before + * the open so no transaction waits on a file read. `owed` is a read that can clear: the tables are + * made, and the copy and the version bump wait for a launch whose read succeeds. + */ +export type JournalLegacyRecordImport = + | { owed: true } + | { owed: false; write: (db: Database.Database) => void } + +export const NO_LEGACY_JOURNAL_RECORDS: JournalLegacyRecordImport = { + owed: false, + write: () => undefined } export function journalPragmaNumber(db: Database.Database, name: string): number { return Number(db.pragma(name, { simple: true }) ?? 0) } -export function openJournalDatabase(dbPath: string): OpenJournalDatabase { +/** Whether an open of a database at `stored` runs version 4's migration, and so reads the records + * file first. */ +export function journalDatabaseMigratesRecords(stored: number): boolean { + return ( + stored === 0 || + (stored >= JOURNAL_DB_OLDEST_RELEASED_VERSION && stored < JOURNAL_DB_SCHEMA_VERSION) + ) +} + +/** 0 for a database not created yet: the probe never creates the file. */ +export function readJournalDatabaseVersion(dbPath: string): number { + if (!existsSync(dbPath)) { + return 0 + } + const probe = new Database(dbPath) + try { + return journalPragmaNumber(probe, 'user_version') + } finally { + probe.close() + } +} + +/** + * Whether the database holds any chat record or tab, read-only. `undefined` while version 4's copy + * of the records file is still owed, so the file answers for the chats it holds. + */ +export function journalDatabaseHoldsAgentSessions(dbPath: string): boolean | undefined { + const db = new Database(dbPath, { readonly: true, fileMustExist: true }) + try { + const tables = new Set( + db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() + .map(({ name }) => name) + ) + const holds = ['agent_session_records', 'agent_session_tabs'].some( + (table) => + tables.has(table) && db.prepare(`SELECT 1 FROM ${table} LIMIT 1`).get() !== undefined + ) + if (holds || journalPragmaNumber(db, 'user_version') >= JOURNAL_DB_SCHEMA_VERSION) { + return holds + } + return undefined + } finally { + db.close() + } +} + +export function openJournalDatabase( + dbPath: string, + legacyRecords: JournalLegacyRecordImport +): OpenJournalDatabase { const probe = new Database(dbPath) let stored: number try { @@ -37,25 +110,29 @@ export function openJournalDatabase(dbPath: string): OpenJournalDatabase { } if (stored > JOURNAL_DB_SCHEMA_VERSION) { probe.close() - return { db: new Database(dbPath, { readonly: true, fileMustExist: true }), readOnly: true } + return { + db: new Database(dbPath, { readonly: true, fileMustExist: true }), + readOnly: true, + legacyRecordImportOwed: false + } } let transferred = false try { - if (stored !== 0 && stored < JOURNAL_DB_SCHEMA_VERSION) { + if (stored !== 0 && stored < JOURNAL_DB_OLDEST_RELEASED_VERSION) { // No retry reads past it, so every chat says it can't load; the log says what to do. throw new JournalUnreleasedSchemaError( - `chat journal ${dbPath} uses unreleased schema ${stored}, written by an unreleased development build of Orca; move the file aside and Orca starts a new one` + `chat journal ${dbPath} uses unreleased schema ${stored}, written by an unreleased development build of Orca. Orca can't load its chats. Moving the file aside lets Orca start a new one, but that loses every chat's history, tabs and ownership records stored in it.` ) } configureJournalPragmas(probe, stored) - createJournalSchema(probe, stored) - // Outside `createJournalSchema` on purpose: its early return skips a db + const legacyRecordImportOwed = migrateJournalSchema(probe, stored, legacyRecords) + // Outside `migrateJournalSchema` on purpose: its early return skips a db // already at the current version, and this table must exist at EVERY // writable open with no `user_version` bump (see `ensureQueuedMessagesTable`). ensureQueuedMessagesTable(probe) hardenSqliteDatabaseFiles(dbPath) transferred = true - return { db: probe, readOnly: false } + return { db: probe, readOnly: false, legacyRecordImportOwed } } finally { if (!transferred) { probe.close() @@ -82,17 +159,35 @@ function configureJournalPragmas(db: Database.Database, stored: number): void { } /** - * Table creation and the `user_version` bump are ONE transaction. Creating the tables first left - * a shaped database still reporting version 0, which an older build does not latch read-only. + * Table creation, the records copy and the `user_version` bump are ONE transaction. Creating the + * tables first left a shaped database still reporting version 0, which an older build does not + * latch read-only; and "copied" is `user_version >= 4`, so no other marker can disagree with it. + * Returns whether the copy is still owed. */ -function createJournalSchema(db: Database.Database, stored: number): void { +function migrateJournalSchema( + db: Database.Database, + stored: number, + legacyRecords: JournalLegacyRecordImport +): boolean { if (stored >= JOURNAL_DB_SCHEMA_VERSION) { - return + return false } runJournalTransaction(db, () => { - db.exec(createJournalTablesSql()) + if (stored === 0) { + db.exec(createJournalTablesSql()) + } + db.exec(createAgentSessionRecordTablesSql()) + if (legacyRecords.owed) { + // A fresh file still takes a released version, so an older build opens it as one. + if (stored === 0) { + db.pragma(`user_version = ${JOURNAL_DB_OLDEST_RELEASED_VERSION}`) + } + return + } + legacyRecords.write(db) db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION}`) }) + return legacyRecords.owed } /** diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts index 6c01e6224dc0..1aaa0b76df95 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts @@ -27,6 +27,9 @@ export const DISPATCH_DOUBT_SUBMISSION_MISSING = 'durable_send_submission_missin * left that could still acknowledge the message. */ export const DISPATCH_DOUBT_PROVIDER_IDLE = 'provider_idle_before_acknowledgement' +/** The provider started the message, then ended its turn before acknowledging it. */ +export const DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED = 'provider_ended_before_acknowledgement' + /** The SDK took the frame, but its input pump did not prove whether the write completed. */ export const DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN = 'provider_write_outcome_unknown' diff --git a/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts b/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts index 9ce0e0c34319..dbe15b4709ed 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts @@ -3,6 +3,7 @@ // same directory reads the same database the way a restarted host would. import { resolve } from 'node:path' +import { NO_LEGACY_JOURNAL_RECORDS } from './journal-database' import { JournalHostDatabase } from './journal-host-database' import { replayJournal, type JournalLoad } from './journal-open' import { serializeJournalRow, type JournalRow } from './journal-row-schema' @@ -25,11 +26,18 @@ export function openTestJournalHostDatabase(stateDirectory: string): JournalHost if (existing && !existing.isClosed) { return existing } - const database = JournalHostDatabase.open(directory) + const database = JournalHostDatabase.openWith(directory, NO_LEGACY_JOURNAL_RECORDS) opened.set(directory, database) return database } +/** Closes this directory's database, so the next open reads the file as a fresh process would. */ +export function closeTestJournalHostDatabase(stateDirectory: string): void { + const directory = resolve(stateDirectory) + opened.get(directory)?.close() + opened.delete(directory) +} + /** Closes every database this process opened for tests. */ export function closeTestJournalHostDatabases(): void { for (const database of opened.values()) { diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.ts b/src/main/native-chat/agent-session-journal/journal-host-database.ts index b6999e4ef559..ca6036d3a9d0 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.ts @@ -10,8 +10,12 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session- import type Database from '../../sqlite/sync-database' import { JOURNAL_SYNCHRONOUS, + journalDatabaseMigratesRecords, + NO_LEGACY_JOURNAL_RECORDS, openJournalDatabase, + readJournalDatabaseVersion, runJournalTransaction, + type JournalLegacyRecordImport, type OpenJournalDatabase } from './journal-database' import { journalOpenRefusalError } from './journal-open-failure' @@ -28,6 +32,8 @@ export class JournalHostDatabase { private connection: Database.Database | null /** A newer Orca wrote the database: every chat's history reads, and no chat writes. */ readonly readOnly: boolean + /** The chat records file could not be read this launch, so its copy waits for a later one. */ + readonly legacyRecordImportOwed: boolean /** A failed transaction's ROLLBACK failed too, so the transaction may still be open. */ private stranded = false @@ -37,13 +43,33 @@ export class JournalHostDatabase { ) { this.connection = opened.db this.readOnly = opened.readOnly + this.legacyRecordImportOwed = opened.legacyRecordImportOwed } - static open(stateDirectory: string): JournalHostDatabase { + /** `readLegacyRecords` runs only when this open migrates to version 4, before any transaction. */ + static async open( + stateDirectory: string, + readLegacyRecords: () => Promise + ): Promise { + mkdirSync(stateDirectory, { recursive: true }) + const migrates = journalDatabaseMigratesRecords( + readJournalDatabaseVersion(journalDatabasePath(stateDirectory)) + ) + return JournalHostDatabase.openWith( + stateDirectory, + migrates ? await readLegacyRecords() : NO_LEGACY_JOURNAL_RECORDS + ) + } + + /** The same open with the records file already read; tests pass `NO_LEGACY_JOURNAL_RECORDS`. */ + static openWith( + stateDirectory: string, + legacyRecords: JournalLegacyRecordImport + ): JournalHostDatabase { mkdirSync(stateDirectory, { recursive: true }) return new JournalHostDatabase( stateDirectory, - openJournalDatabase(journalDatabasePath(stateDirectory)) + openJournalDatabase(journalDatabasePath(stateDirectory), legacyRecords) ) } diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts index 2e9e21d024f4..44a68b7696bd 100644 --- a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' -import { openJournalDatabase } from './journal-database' +import { NO_LEGACY_JOURNAL_RECORDS, openJournalDatabase } from './journal-database' import { classifyJournalOpenFailure, createJournalOpenReadRefusals, @@ -28,7 +28,7 @@ afterEach(async () => { /** What the journal's own open, then a chat's replay, throws for the file as it stands. */ function openFailure(): unknown { try { - const db = openJournalDatabase(journalDatabasePath(root)).db + const db = openJournalDatabase(journalDatabasePath(root), NO_LEGACY_JOURNAL_RECORDS).db try { replayJournal(db, 'session-1') } finally { @@ -59,7 +59,7 @@ describe('classifyJournalOpenFailure', () => { it('calls a journal whose pages are damaged corrupt', async () => { const path = journalDatabasePath(root) - const opened = openJournalDatabase(path).db + const opened = openJournalDatabase(path, NO_LEGACY_JOURNAL_RECORDS).db opened.exec('PRAGMA journal_mode = DELETE') opened.close() const bytes = await readFile(path) @@ -79,15 +79,6 @@ describe('classifyJournalOpenFailure', () => { expect(classifyJournalOpenFailure(nodeSqliteError(errcode))).toBe('journalCorrupt') }) - it("reads the Bun driver's corrupt code as corrupt", () => { - const error = Object.assign(new Error('file is not a database'), { - name: 'SQLiteError', - code: 'SQLITE_NOTADB', - errno: 26 - }) - expect(classifyJournalOpenFailure(error)).toBe('journalCorrupt') - }) - it('finds corruption a wrapper names as its cause', () => { const wrapped = new Error('opening the conversation failed', { cause: new Error('the journal would not open', { cause: nodeSqliteError(11) }) diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts b/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts index ea28ba4ef838..c9737bf20079 100644 --- a/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts @@ -331,23 +331,21 @@ describe('importing a per-chat journal', () => { }) ) const between: number[] = [] - let copying = true const tick = (): void => { // What a live chat's append would commit under, between two copy batches. between.push(synchronous()) - if (copying) { - setImmediate(tick) - } + pending = setImmediate(tick) } - setImmediate(tick) + let pending = setImmediate(tick) await importPerSessionJournal({ database, identity: IDENTITY, legacyDirectory: legacyDir(), batchRows: 1 }) - copying = false + // A flag would still let the queued tick read the database after teardown closes it. + clearImmediate(pending) // 2 is FULL, 1 is NORMAL. expect(commits.slice(0, rows.length)).toEqual(rows.map(() => 1)) diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts index 55b338366f67..06c5ba73bdca 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from '../agent-session-journal/journal-payload-bounds' import { classifyProviderFrame, + hasTypedProviderFrameTranslator, isDeltaProviderFrameKind, PROVIDER_FRAME_CLASSIFICATIONS } from './provider-frame-disposition' @@ -199,7 +200,22 @@ describe('typed translator coverage', () => { ).toMatchObject({ classification: 'error-surface' }) }) - it('covers Claude only — the same method name on another provider still falls back', () => { + it('covers the Codex thread status, which reports `systemError` beside the `error` row', () => { + const kind = 'notification:thread/status/changed' + const payload = { threadId: 'thread-1', status: { type: 'systemError' } } + + expect(hasTypedProviderFrameTranslator('codex', kind)).toBe(true) + expect( + unhandledProviderFrameJournalItem('codex', kind, payload, DEFAULT_JOURNAL_PAYLOAD_LIMITS, { + coveredByTypedTranslator: true + }) + ).toBeNull() + expect(unhandledProviderFrameJournalItem('codex', kind, payload)).toMatchObject({ + classification: 'error-surface' + }) + }) + + it('covers a kind only for its own provider — a Claude kind from Codex still falls back', () => { expect( unhandledProviderFrameJournalItem('codex', 'message:system:task_notification', { status: 'failed' diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts index 22b3b26a453c..4cc90430d7e2 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts @@ -162,23 +162,39 @@ export const PROVIDER_FRAME_CLASSIFICATIONS = { * * Listing a kind before its translator exists deletes the only report of a * failure, so nothing may be added here except together with the code that - * renders it. + * renders it, or that reads it for state while another frame carries its + * failure (the Codex thread status, whose fault arrives on `error`). * - * A frame of a listed kind that names no task writes nothing. The row it + * A Claude frame of a listed kind that names no task writes nothing. The row it * replaces named no task either — it printed the opcode and a raw payload — * and every frame this protocol sends carries the id its own tracker and * roster have always required. */ -const CLAUDE_TYPED_TRANSLATOR_KINDS: ReadonlySet = new Set([ - 'message:system:task_started', - 'message:system:task_updated', - 'message:system:task_progress', - 'message:system:task_notification', - 'message:system:background_tasks_changed' -] satisfies ClaudeStreamJsonFrameKind[]) +const TYPED_TRANSLATOR_KINDS: ReadonlyMap> = new Map< + keyof ProviderFrameClassificationTable, + ReadonlySet +>([ + [ + 'claude', + new Set([ + 'message:system:task_started', + 'message:system:task_updated', + 'message:system:task_progress', + 'message:system:task_notification', + 'message:system:background_tasks_changed' + ] satisfies ClaudeStreamJsonFrameKind[]) + ], + [ + 'codex', + // Every status arm is thread state the translator reads; a fault's sentence arrives on `error`. + new Set([ + 'notification:thread/status/changed' + ] satisfies `notification:${CodexAppServerNotificationMethod}`[]) + ] +]) export function hasTypedProviderFrameTranslator(provider: string, kind: string): boolean { - return provider === 'claude' && CLAUDE_TYPED_TRANSLATOR_KINDS.has(kind) + return TYPED_TRANSLATOR_KINDS.get(provider)?.has(kind) === true } const ERROR_VARIANT_KEYS = new Set(['type', 'status', 'state', 'subtype', 'outcome']) @@ -238,10 +254,10 @@ const CODEX_ITEM_CLASSIFICATIONS: Record = // `restoreThread` replays them straight through `items.handle`, which is where // the classification earns its keep. // - // `collabAgentToolCall` is deliberately NOT suppressed with it. Nothing - // guarantees a session reports subagent work as `subAgentActivity` at all; one - // that only ever emits the collab tool call gets no roster row, and suppressing - // that too would leave its fan-out showing nothing. + // `collabAgentToolCall` is deliberately NOT suppressed with it. Codex's default + // multi-agent mode reports subagent work ONLY as that call, which renders as its + // own tool row and never reaches this catalog; were it ever to, suppressing it + // would leave that session's fan-out showing nothing. [CODEX_SUBAGENT_ITEM_TYPE]: 'status-chrome', // `{id, durationMs}` and nothing else — Codex's own transcript renders it as // nothing at all. Every other item type this build does not model carries text diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index b7371ee40f36..a80c147607df 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -231,7 +231,7 @@ function deferred() { async function writeAsEarlierProcess( write: (journal: AgentSessionJournal, fence: number) => Promise ): Promise { - await host.close(SESSION) + await host.close(SESSION, 'evict') const record = store.getRecord(SESSION)! const params = attachParamsForRecord(record, { clientOperationId: 'earlier', @@ -261,7 +261,7 @@ function earlierSubmission(id: string, text: string, handoverRecorded?: true) { describe('a send is answered at acceptance', () => { it('answers before the child starts, then an open chat sees the handover and the reply (W2)', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') const starting = deferred() acquire.mockImplementationOnce(async (input) => { await starting.promise @@ -289,7 +289,7 @@ describe('a send is answered at acceptance', () => { }) it('accepts a second send while the first one starts the child, before handing either over (W6)', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') const starting = deferred() acquire.mockImplementationOnce(async (input) => { await starting.promise @@ -320,7 +320,7 @@ describe('a send is answered at acceptance', () => { describe('a start the chat needed and did not get', () => { it('writes one error row and rejects every queued message with it; the next send starts (W3)', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')) const first = await accept('first') const second = await accept('second') @@ -350,7 +350,7 @@ describe('a start the chat needed and did not get', () => { }) it('draws the messages it failed above the error row, since they were accepted first', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')) const first = await accept('first') const second = await accept('second') @@ -369,7 +369,7 @@ describe('a start the chat needed and did not get', () => { }) it('notifies failed once for the queued messages one start failure refused', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')) const completions: AgentSessionTurnCompletionEvent[] = [] host.subscribeTurnCompletions({ id: 'dot-1', emit: (event) => completions.push(event) }) @@ -434,7 +434,7 @@ describe('a start the chat needed and did not get', () => { } ] ])('writes one row a live chat sees for a %s refusal (W14)', async (_source, arrange, row) => { - await host.close(SESSION) + await host.close(SESSION, 'evict') arrange() await host.flushAllStreamedEvents() await startHost() @@ -455,7 +455,7 @@ describe('a start the chat needed and did not get', () => { }) it('names the start failure on queued messages when the attach fails after acquiring (W4′a)', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') const id = await accept('hello') // The attach's own success record is the post-acquisition step that fails. const record = vi.spyOn(store, 'recordOperationOutcome') @@ -487,7 +487,7 @@ describe('a start the chat needed and did not get', () => { describe('an attach that fails after indexing its child', () => { it('leaves no child behind, so the next send starts one and is delivered', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') const owned: boolean[] = [] host.subscribeStatus({ id: 'list-1', @@ -578,7 +578,7 @@ describe('a child that exits before its message is handed over', () => { }) }) adapterExtras = { awaitStarted } - await host.close(SESSION) + await host.close(SESSION, 'evict') await startHost() const id = await accept('hello') @@ -602,7 +602,7 @@ describe('a start whose failure the delivery loop settles before the exit is pub ...(await spawnChild(input)), providerChildPhase: 'starting' as const })) - await host.close(SESSION) + await host.close(SESSION, 'evict') await startHost() const first = await accept('first') @@ -659,7 +659,7 @@ describe('Stop withdraws what is queued', () => { }) it('withdraws a message whose start holds the queue: nothing is handed over (W17b)', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') const starting = deferred() acquire.mockImplementationOnce(async (input) => { await starting.promise @@ -685,7 +685,7 @@ describe('Stop withdraws what is queued', () => { return true }) adapterExtras = { awaitStarted, closeSession } - await host.close(SESSION) + await host.close(SESSION, 'evict') await startHost() acquire.mockImplementationOnce(async (input) => ({ ...(await spawnChild(input)), @@ -713,12 +713,12 @@ describe('an eviction between acceptance and handover', () => { it('rejects the message as not sent, never leaves it in doubt (W24)', async () => { const started = deferred() adapterExtras = { awaitStarted: () => started.promise } - await host.close(SESSION) + await host.close(SESSION, 'evict') await startHost() const id = await accept('hello') await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) // Between the delivery loop's start step and its handover step. - await host.close(SESSION) + await host.close(SESSION, 'evict') started.resolve() expect(await reopened(id)).toMatchObject({ @@ -732,7 +732,7 @@ describe('an eviction between acceptance and handover', () => { const second = deferred() const awaitStarted = vi.fn(async (): Promise => undefined) adapterExtras = { awaitStarted } - await host.close(SESSION) + await host.close(SESSION, 'evict') await startHost() dispatch.mockResolvedValueOnce({ state: 'admitted' }) const handed = await accept('handed over') @@ -741,7 +741,7 @@ describe('an eviction between acceptance and handover', () => { const queued = await accept('still queued') await eventually(async () => expect(awaitStarted).toHaveBeenCalledTimes(2)) - await host.close(SESSION) + await host.close(SESSION, 'evict') second.resolve() expect(await reopened(queued)).toMatchObject({ @@ -756,7 +756,7 @@ describe('an eviction between acceptance and handover', () => { idleMs = 0 const started = deferred() adapterExtras = { awaitStarted: () => started.promise } - await host.close(SESSION) + await host.close(SESSION, 'evict') await startHost() const id = await accept('hello') await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) @@ -775,7 +775,7 @@ describe('an eviction between acceptance and handover', () => { // leaves every queued message rejected as closed, never blamed on the provider. describe('a close that stops the child and then fails', () => { const END_CHILD = { - evict: () => host.close(SESSION) + evict: () => host.close(SESSION, 'evict') } satisfies Partial Promise>> it.each([ @@ -792,7 +792,7 @@ describe('a close that stops the child and then fails', () => { throw new Error('release acknowledgement failed') }) } - await host.close(SESSION) + await host.close(SESSION, 'evict') await startHost() acquire.mockImplementationOnce(async (input) => ({ ...(await spawnChild(input)), @@ -816,7 +816,7 @@ describe('a close that stops the child and then fails', () => { describe('a compaction or rewind an earlier child left prepared', () => { async function leftPrepared(prepare: (fence: number) => Promise): Promise { - await host.close(SESSION) + await host.close(SESSION, 'evict') await prepare(store.getRecord(SESSION)!.lease.runtimeFence) // A new process: nothing is open and no view attaches. await host.flushAllStreamedEvents() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts index ac2f1823e5fc..6d6589521df7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts @@ -3,9 +3,9 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { AgentSessionPreSpawnError, isAgentSessionPreSpawnError, - rethrowAfterAgentSessionAcquisitionCleanup, type StructuredAgentSessionProviderChildPhase } from './structured-agent-session-adapter' +import { rethrowAfterAgentSessionAcquisitionCleanup } from './structured-agent-session-provider-exit-proof' import { journalIdentityFor } from './structured-agent-session-attach' import type { AttachFlowInput } from './structured-agent-session-attach-flow' import { readNativeSessionOptions } from './structured-agent-session-option-restoration' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts index e08c289c87a8..7de5dc5033b2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from 'vitest' -import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalQuestionItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' import type { AgentSessionAcquisition, StructuredAgentSessionAdapter @@ -161,11 +164,58 @@ describe('StructuredAgentSessionAdapterRouter optional lifecycle methods', () => const stopSession = router[method] await expect(stopSession('session-1')).resolves.toBe(true) - expect(closeSession).toHaveBeenCalledWith('session-1') + // A host with no cause to name passes none; the adapter settles its turn as news. + expect(closeSession).toHaveBeenCalledWith('session-1', undefined) } ) }) +const QUESTION: AgentJournalQuestionItem = { + kind: 'question', + question: 'Which branch?', + options: [{ id: 'main', label: 'main' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } +} + +describe('StructuredAgentSessionAdapterRouter.stopEndsSession', () => { + it("answers for the session's live owner, and keeps the child with none", async () => { + const claude = adapterOf(vi.fn(async () => true)) + claude.stopEndsSession = () => true + const codex = adapterOf(vi.fn(async () => false)) + const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + + expect(router.stopEndsSession('session-1')).toBe(false) + await router.acquire({ identity: claudeIdentity('session-1'), fence: 1, spawnToken: 'spawn-1' }) + expect(router.stopEndsSession('session-1')).toBe(true) + }) + + it("waits on the session's live owner to wind the Stop down, and on nothing with none", async () => { + const claude = adapterOf(vi.fn(async () => true)) + claude.awaitStoppedRequestEnd = vi.fn(async () => undefined) + const codex = adapterOf(vi.fn(async () => false)) + const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + + await router.awaitStoppedRequestEnd('session-1', 5) + expect(claude.awaitStoppedRequestEnd).not.toHaveBeenCalled() + await router.acquire({ identity: claudeIdentity('session-1'), fence: 1, spawnToken: 'spawn-1' }) + await router.awaitStoppedRequestEnd('session-1', 5) + expect(claude.awaitStoppedRequestEnd).toHaveBeenCalledWith('session-1', 5) + }) + + it("answers a card's Cancel as the session's live owner does, and leaves it to cancelTurn with none", async () => { + const claude = adapterOf(vi.fn(async () => true)) + claude.routePromptCancel = () => ({ kind: 'stop' }) + const codex = adapterOf(vi.fn(async () => false)) + const router = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => {}) + + expect(router.routePromptCancel({ sessionId: 'session-1', prompt: QUESTION })).toBeUndefined() + await router.acquire({ identity: claudeIdentity('session-1'), fence: 1, spawnToken: 'spawn-1' }) + expect(router.routePromptCancel({ sessionId: 'session-1', prompt: QUESTION })).toEqual({ + kind: 'stop' + }) + }) +}) + describe('StructuredAgentSessionAdapterRouter.closeAll', () => { it('refuses to acquire once the global close proof is published', async () => { const acquire = vi.fn(async ({ fence, spawnToken }) => acquisition(fence, spawnToken)) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index bb003f93427d..8e43cfb914f0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -1,10 +1,14 @@ import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' +import type { StructuredAgentSessionAtRestCommands } from './structured-agent-session-at-rest-commands' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionAccountHome, AgentSessionExecutionLocation } from '../../../shared/agent-session-record' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { + StructuredAgentSessionAdapter, + StructuredAgentSessionStopCause +} from './structured-agent-session-adapter' type RoutedAgent = 'claude' | 'codex' type SessionRoute = { adapter: StructuredAgentSessionAdapter; state: 'live' | 'stopped' } @@ -108,13 +112,38 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return stop ? stop(input) : Promise.resolve({ cancelled: false }) } - backgroundTaskState: NonNullable = ( + backgroundTaskStops: NonNullable = ( sessionId - ) => this.liveOwnerOrNull(sessionId)?.backgroundTaskState?.(sessionId) + ) => this.liveOwnerOrNull(sessionId)?.backgroundTaskStops?.(sessionId) + + holdsDispatch = (sessionId: string): boolean => + this.liveOwnerOrNull(sessionId)?.holdsDispatch?.(sessionId) ?? false + + stopEndsSession = (sessionId: string): boolean => + this.liveOwnerOrNull(sessionId)?.stopEndsSession?.(sessionId) ?? false + + awaitStoppedRequestEnd = async (sessionId: string, stoppedAt: number) => + this.liveOwnerOrNull(sessionId)?.awaitStoppedRequestEnd?.(sessionId, stoppedAt) + + routePromptCancel: NonNullable = (input) => + this.liveOwnerOrNull(input.sessionId)?.routePromptCancel?.(input) + + dismissPrompt: NonNullable = async (input) => + this.owner(input.sessionId).dismissPrompt?.(input) readCommands: NonNullable = (sessionId) => this.liveOwnerOrNull(sessionId)?.readCommands?.(sessionId) + atRestCommands: StructuredAgentSessionAtRestCommands = { + read: (record) => this.adapters[record.provider].atRestCommands?.read(record), + onChange: (listener) => { + const stops = Object.values(this.adapters).flatMap((adapter) => + adapter.atRestCommands ? [adapter.atRestCommands.onChange(listener)] : [] + ) + return () => stops.forEach((stop) => stop()) + } + } + answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) => this.owner(input.sessionId).answerPrompt(input) @@ -145,20 +174,21 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi accountHome: AgentSessionAccountHome }) => this.requireAgent(input.identity).providerHistoryWindow?.(input) ?? Promise.resolve(null) - closeSession = (sessionId: string): Promise => - this.stopSession(sessionId, (adapter) => adapter.closeSession) + closeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => + this.stopSession(sessionId, (adapter) => adapter.closeSession, cause) forceCloseSession = (sessionId: string): Promise => this.stopSession(sessionId, (adapter) => adapter.forceCloseSession ?? adapter.closeSession) - disposeSession = (sessionId: string): Promise => - this.stopSession(sessionId, (adapter) => adapter.disposeSession ?? adapter.closeSession) + disposeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => + this.stopSession(sessionId, (adapter) => adapter.disposeSession ?? adapter.closeSession, cause) private async stopSession( sessionId: string, selectStop: ( adapter: StructuredAgentSessionAdapter - ) => NonNullable | undefined + ) => NonNullable | undefined, + cause?: StructuredAgentSessionStopCause ): Promise { const route = this.routes.get(sessionId) if (!route) { @@ -171,7 +201,7 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return true } const stop = selectStop(route.adapter) - const stopped = await stop?.call(route.adapter, sessionId) + const stopped = await stop?.call(route.adapter, sessionId, cause) if (stopped === true) { route.state = 'stopped' return true diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts new file mode 100644 index 000000000000..0d73ec723ce8 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts @@ -0,0 +1,37 @@ +// How a provider's Stop, and a prompt card's own Cancel, end what they end. Every member is +// optional: a provider that declares none keeps its child after a Stop, and its card's Cancel +// interrupts the turn holding the card. + +import type { + AgentJournalApprovalItem, + AgentJournalQuestionItem +} from '../../../shared/agent-session-journal-types' + +/** Where a card's Cancel goes: a dismissal (`dismissPrompt`), or the chat's Stop. */ +export type AgentSessionPromptCancelRoute = { kind: 'dismiss' } | { kind: 'stop' } + +export type StructuredAgentSessionAdapterStop = { + /** A Stop ends this provider's child after `cancelTurn`, whatever it answered, unless it named a + * turn that is no longer live and the cancel answered that it did not take it; the next send + * resumes the conversation. Absent or false keeps the child after a Stop. */ + stopEndsSession?(sessionId: string): boolean + /** What a Stop that ends the session waits on before it ends the child: resolves once the provider + * has nothing in flight, a send it has not answered included, or when its grace, counted from + * `stoppedAt` (when the interrupt went out), runs out. */ + awaitStoppedRequestEnd?(sessionId: string, stoppedAt: number): Promise + /** Where the pending card's own Cancel goes. Undefined: `cancelTurn` with the prompt. */ + routePromptCancel?(input: { + sessionId: string + prompt: AgentJournalApprovalItem | AgentJournalQuestionItem + }): AgentSessionPromptCancelRoute | undefined + /** The user dismissed the pending card; `commit` records it as cancelled, with the claim held. + * `answer` then declines the provider's request; without it the request is left to end with the + * child a Stop ends. Either way the provider records nothing more for the card. */ + dismissPrompt?(input: { + sessionId: string + itemId: string + fence: number + answer: boolean + commit: () => Promise + }): Promise +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts index 93f9b9037df7..daa7d2028428 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts @@ -4,9 +4,9 @@ import { AgentSessionAcquisitionExitProvenError, AgentSessionAcquisitionExitUnprovenError, AgentSessionAcquisitionRefusal, - AgentSessionAcquisitionRootExitObservedError, - rethrowAfterAgentSessionAcquisitionCleanup + AgentSessionAcquisitionRootExitObservedError } from './structured-agent-session-adapter' +import { rethrowAfterAgentSessionAcquisitionCleanup } from './structured-agent-session-provider-exit-proof' describe('failed agent-session acquisition cleanup', () => { it('names a failure exit-proven after proven cleanup, keeping its diagnostic and cause', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index a689334c8b43..91f6d945c50b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -10,6 +10,7 @@ import type { // take this?" — and it answers `unknown` rather than guessing, because the // journal renders that as delivery unconfirmed instead of as failure. +import type { AgentSessionBackgroundTaskStops } from '../../../shared/agent-child-work-stop-targets' import type { AgentJournalItemIdentity, AgentJournalItemBody, @@ -24,20 +25,23 @@ import type { AgentSessionExecutionLocation, AgentSessionProcessIdentity } from '../../../shared/agent-session-record' +import type { StructuredAgentSessionAtRestCommands } from './structured-agent-session-at-rest-commands' import type { - AgentSessionBackgroundTaskState, AgentSessionOptionsResult, AgentSessionSlashCommand, AgentSessionThreadGoalChange } from '../../../shared/agent-session-wire' -import { - isAgentSessionWireRefusalCode, - type AgentSessionRefusalReason -} from '../../../shared/agent-session-wire-refusals' +import type { AgentSessionRefusalReason } from '../../../shared/agent-session-wire-refusals' import type { ProviderDiagnostic, SubmissionRejectionFact } from '../../../shared/agent-session-failure' +import type { StructuredAgentSessionStopCause } from './structured-agent-session-stop-cause' +import type { StructuredAgentSessionAdapterStop } from './structured-agent-session-adapter-stop' +export type { + StructuredAgentSessionChildEndCause, + StructuredAgentSessionStopCause +} from './structured-agent-session-stop-cause' import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' @@ -186,6 +190,8 @@ export type StructuredAgentSessionEndedEvent = { * Orca fault. Absent reads as a provider exit with nothing to add. */ failure?: SubmissionRejectionFact cause: 'unexpected-exit' | 'requested-close' + /** With `requested-close`: who asked for it. Absent when the host named no cause. */ + stopCause?: StructuredAgentSessionStopCause fence: number acquisitionGeneration: string /** Host receipt of the child exit: the end time of a turn it interrupted. */ @@ -242,7 +248,7 @@ export type AgentSessionCancelOutcome = { refusal?: { detail?: ProviderDiagnostic } } -export type StructuredAgentSessionAdapter = { +export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & { /** Provider-aware capability check for hosts that route more than one adapter. */ supportsCreate?(location: AgentSessionExecutionLocation, agent: string): boolean /** Provider/runtime support, kept here so remote enablement changes adapter data, not UI logic. */ @@ -325,15 +331,23 @@ export type StructuredAgentSessionAdapter = { supportsThreadGoal?(sessionId: string, agent?: string): boolean /** Whether this session writes context facts to its turn rows; `agent` answers one at rest. */ recordsContextUsage?(sessionId: string, agent?: string): boolean + /** Stops exactly the tasks `taskIds` names, which the host resolves from its child records. */ stopBackgroundTasks?(input: { sessionId: string fence: number - taskId?: string + taskIds: readonly string[] }): Promise<{ cancelled: boolean }> - backgroundTaskState?(sessionId: string): AgentSessionBackgroundTaskState | null | undefined + /** The stops this provider honours for a live session's background work; undefined when the + * adapter holds no live session for it. */ + backgroundTaskStops?(sessionId: string): AgentSessionBackgroundTaskStops | undefined + /** The provider reported taking a send it has neither answered nor ended, as a queued follow-up + * or a silent retry does. Derived from the live child; false with none. */ + holdsDispatch?(sessionId: string): boolean /** The `/` surface the running provider reports for itself. Undefined when the * provider never reports one, which is what keeps the client on its catalog. */ readCommands?(sessionId: string): AgentSessionSlashCommand[] | undefined + /** The `/` surface of a chat whose agent is not running; absent when the provider has none. */ + atRestCommands?: StructuredAgentSessionAtRestCommands /** Claims the live callback, builds the provider reply, commits the journal CAS while that claim is * held, then answers it. A reply that cannot be built throws `AgentSessionPromptAnswerRejectedError` * before the commit. A prompt cancel claims the same callback, so only one operation can commit. */ @@ -372,65 +386,11 @@ export type StructuredAgentSessionAdapter = { /** Gracefully stops the structured owner after its event stream is drained. */ /** Returns true only after the provider child exit is proven. A root-exit or processless verdict * is thrown only once the session is finalized; read it through `stopAgentSessionProviderRoot`. */ - closeSession?(sessionId: string): Promise + closeSession?(sessionId: string, cause?: StructuredAgentSessionStopCause): Promise /** Stops a provider after a sink failure; the resulting exit is recovered as unexpected. */ forceCloseSession?(sessionId: string): Promise /** Stops a provider child for teardown without requiring a future-resume cursor. */ - disposeSession?(sessionId: string): Promise + disposeSession?(sessionId: string, cause?: StructuredAgentSessionStopCause): Promise /** Host acknowledgement that the proven-dead child, lease and journal owner are released. */ acknowledgeSessionRelease?(sessionId: string): void } - -export async function rethrowAfterAgentSessionAcquisitionCleanup( - adapter: Pick, - sessionId: string, - cause: unknown -): Promise { - let released: boolean - try { - released = (await adapter.releaseAcquisition?.({ sessionId })) === true - } catch (cleanupError) { - // A root exit the cleanup observed first-hand keeps its classification and its - // provider diagnostic; the failure that triggered cleanup rides along as cause. - throw cleanupError instanceof AgentSessionAcquisitionRootExitObservedError - ? new AgentSessionAcquisitionRootExitObservedError( - new AggregateError([cause, cleanupError], cleanupError.message) - ) - : new AgentSessionAcquisitionExitUnprovenError( - new AggregateError([cause, cleanupError], 'agent session acquisition cleanup failed') - ) - } - if (released) { - throw provenExitAcquisitionFailure(cause) - } - throw new AgentSessionAcquisitionExitUnprovenError(cause) -} - -/** A failure whose child cleanup proved gone. One that already names its own verdict — a - * refusal, a typed exit proof, or a host store code — keeps it. */ -function provenExitAcquisitionFailure(cause: unknown): unknown { - const classified = - cause instanceof AgentSessionAcquisitionRefusal || - cause instanceof AgentSessionAcquisitionRootExitObservedError || - cause instanceof AgentSessionAcquisitionExitUnprovenError || - isAgentSessionPreSpawnError(cause) || - (cause instanceof Error && isAgentSessionWireRefusalCode(cause.message)) - return classified ? cause : new AgentSessionAcquisitionExitProvenError(cause) -} - -/** Whether a stop left the provider root gone. The lease follows the root, so a first-hand root - * exit or a processless child ends the session whatever its descendants did; any other - * failure still throws. */ -export async function stopAgentSessionProviderRoot(stop: () => Promise): Promise { - try { - return (await stop()) === true - } catch (error) { - if ( - error instanceof AgentSessionAcquisitionRootExitObservedError || - isAgentSessionPreSpawnError(error) - ) { - return true - } - throw error - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts new file mode 100644 index 000000000000..063cf84ebd79 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts @@ -0,0 +1,319 @@ +// A Claude chat whose Claude is not running offers the `/` commands and skills Claude would read +// from its folders on the host that runs it: after a /clear, after a relaunch, as files change. + +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { + AgentSessionSlashCommand, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' +import { + CLAUDE_AT_REST_COMMANDS_TTL_MS, + ClaudeAtRestCommandCatalog +} from '../../claude/claude-at-rest-commands' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_LOCATION, + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' + +const caller = { callerKey: 'desktop' } +const CLAUDE_SESSION = '819cf9f8-e43c-4ad7-b50f-54aa158a726a' + +let directory: string +let workspace: string +let account: string +let clock: number +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost + +/** Claude is never running here, so the only `/` surface is the one read from its folders. */ +function adapter(catalog: ClaudeAtRestCommandCatalog): StructuredAgentSessionAdapter { + return { + supportsLocation: () => true, + supportsCreate: () => true, + acquire: vi.fn(async (input) => ({ + process: { + hostId: 'local', + pid: 4001, + processStartTimeMs: HOST_TEST_NOW, + spawnToken: input.spawnToken + }, + link: { + linkId: `link-${input.fence}`, + mintedAtFence: input.fence, + observedAt: HOST_TEST_NOW, + origin: 'created' as const, + handle: { provider: 'claude' as const, sessionId: CLAUDE_SESSION, leafUuid: null } + } + })), + atRestCommands: catalog, + dispatch: vi.fn(async () => ({ state: 'unknown' as const, reason: 'test' })), + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async () => {}, + setOption: async () => {}, + releaseAcquisition: async () => true, + closeSession: async () => true + } +} + +function catalogFor(workspacePath: string): ClaudeAtRestCommandCatalog { + return new ClaudeAtRestCommandCatalog({ + resolveWorkspacePath: async () => workspacePath, + now: () => clock, + // Only the folders this test writes, never the machine's own home. + discover: async (args) => { + const { discoverSkills } = await import('../../skills/discovery') + return discoverSkills({ ...args, homeDir: join(directory, 'home'), refresh: true }) + } + }) +} + +async function openHost(catalog = catalogFor(workspace)): Promise { + store = await openTestAgentSessionRecordStore(directory) + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(catalog), + journalDatabase: openTestJournalHostDatabase(directory), + claimKeyId: 'key', + now: () => clock, + probeOwner: async () => ({ outcome: 'pid-absent' }) + }) +} + +async function writeSkill(root: string, name: string): Promise { + await mkdir(join(root, name), { recursive: true }) + await writeFile(join(root, name, 'SKILL.md'), `---\nname: ${name}\ndescription: A skill\n---\n`) +} + +async function writeCommand(root: string, file: string): Promise { + await mkdir(join(root, file, '..'), { recursive: true }) + await writeFile(join(root, file), '---\ndescription: A command\n---\nDo it.\n') +} + +/** The `/` surface a pane subscribed to `sessionId` shows once the scan has landed. */ +async function menuOf(sessionId: string): Promise { + let commands: AgentSessionSlashCommand[] | null | undefined + const emit = (event: AgentSessionSubscribeEvent) => { + if ('commands' in event) { + commands = event.commands + } + } + const unsubscribe = await host.subscribe({ id: `pane-${sessionId}`, sessionId, emit }) + await vi.waitFor(() => expect(commands).toBeTruthy()) + unsubscribe() + return commands ?? null +} + +const namesOf = (menu: AgentSessionSlashCommand[] | null, kind: 'skill' | 'command') => + (menu ?? []).filter((entry) => entry.kind === kind).map((entry) => entry.name) + +beforeEach(async () => { + resetHostTestOperationIds() + clock = HOST_TEST_NOW + directory = await mkdtemp(join(tmpdir(), 'orca-at-rest-commands-')) + workspace = join(directory, 'workspace') + account = join(directory, 'claude-account') + await writeSkill(join(workspace, '.claude', 'skills'), 'review-pr') + await writeCommand(join(account, 'commands'), 'deploy.md') + await openHost() + const attached = await host.attach( + caller, + hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: account }, + providerHandle: { kind: 'claude', sessionId: CLAUDE_SESSION, leafUuid: null } + }) + ) + expect(attached).toMatchObject({ ok: true }) + await host.setSessionTabVisibility(SESSION, true) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(directory, { recursive: true, force: true }) +}) + +describe("a Claude chat whose Claude isn't running shows the `/` surface from its folders", () => { + it('after /clear, before the new chat has started', async () => { + const cleared = await host.conversationCommand(caller, { + command: 'clear', + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.conversationCommand', + sessionId: SESSION, + fields: { command: 'clear' } + }) + } + }) + const replacement = cleared.ok ? cleared.value.replacementSessionId! : '' + const menu = await menuOf(replacement) + expect(namesOf(menu, 'skill')).toEqual(['review-pr']) + // Everything the menu offered before a list existed, then what the folders add. + expect(namesOf(menu, 'command')).toEqual(['model', 'effort', 'clear', 'compact', 'deploy']) + }) + + it('keeps a slow scan once it answers, never a staler one after a newer, and runs at most two', async () => { + const pending: ((skills: string[]) => void)[] = [] + const catalog = new ClaudeAtRestCommandCatalog({ + resolveWorkspacePath: async () => workspace, + now: () => clock, + discover: () => + new Promise((resolve) => { + pending.push((names) => + resolve({ + skills: names.map((name) => ({ + id: name, + name, + description: null, + providers: ['claude'], + sourceKind: 'repo', + sourceLabel: 'repo', + rootPath: workspace, + directoryPath: workspace, + skillFilePath: workspace, + installed: true, + updatedAt: null + })), + sources: [], + scannedAt: clock + }) + ) + }) + }) + const record = store.getRecord(SESSION)! + const skills = () => namesOf(catalog.read(record) ?? null, 'skill') + const changed = vi.fn() + catalog.onChange(changed) + catalog.read(record) + await vi.waitFor(() => expect(pending).toHaveLength(1)) + // Overdue: a second scan starts beside the first; a third never does while both run. + clock += 30_000 + catalog.read(record) + await vi.waitFor(() => expect(pending).toHaveLength(2)) + clock += 30_000 + catalog.read(record) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(pending).toHaveLength(2) + // The second answers first; the first, older one landing after it changes nothing. + pending[1](['newer']) + await vi.waitFor(() => expect(skills()).toEqual(['newer'])) + pending[0](['older']) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(skills()).toEqual(['newer']) + expect(changed).toHaveBeenCalledTimes(1) + // A slow scan is not thrown away: the next one, however long it takes, is kept. + clock += CLAUDE_AT_REST_COMMANDS_TTL_MS + catalog.read(record) + await vi.waitFor(() => expect(pending).toHaveLength(3)) + clock += 35_000 + pending[2](['slow']) + await vi.waitFor(() => expect(skills()).toEqual(['slow'])) + }) + + it('waits out the window from when a scan lands, even one that failed', async () => { + let calls = 0 + let failNext = true + const catalog = new ClaudeAtRestCommandCatalog({ + resolveWorkspacePath: async () => workspace, + now: () => clock, + discover: async (args) => { + calls += 1 + // Each scan takes 15 s of the clock, longer than the window. + clock += 15_000 + if (failNext) { + failNext = false + throw new Error('unreadable') + } + const { discoverSkills } = await import('../../skills/discovery') + return discoverSkills({ ...args, homeDir: join(directory, 'home'), refresh: true }) + } + }) + const record = store.getRecord(SESSION)! + const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + try { + catalog.read(record) + await vi.waitFor(() => expect(warned).toHaveBeenCalledOnce()) + catalog.read(record) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(calls).toBe(1) + clock += CLAUDE_AT_REST_COMMANDS_TTL_MS + catalog.read(record) + await vi.waitFor(() => + expect(namesOf(catalog.read(record) ?? null, 'skill')).toEqual(['review-pr']) + ) + clock += CLAUDE_AT_REST_COMMANDS_TTL_MS - 1 + catalog.read(record) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(calls).toBe(2) + } finally { + warned.mockRestore() + } + }) + + it('after a relaunch, with nothing remembered from before it', async () => { + await host.flushAllStreamedEvents() + await openHost() + const menu = await menuOf(SESSION) + expect(namesOf(menu, 'skill')).toEqual(['review-pr']) + expect(namesOf(menu, 'command')).toContain('deploy') + }) + + it('with a command added on disk, pushed to a pane already showing the chat', async () => { + let commands: AgentSessionSlashCommand[] | null | undefined + await host.subscribe({ + id: 'pane', + sessionId: SESSION, + emit: (event) => { + if ('commands' in event) { + commands = event.commands + } + } + }) + await vi.waitFor(() => expect(namesOf(commands ?? null, 'command')).toContain('deploy')) + await writeCommand(join(workspace, '.claude', 'commands'), 'frontend/test.md') + clock += CLAUDE_AT_REST_COMMANDS_TTL_MS + // Anything that reads the surface after the window scans again; the pane is told the answer. + host.readCommands(SESSION) + await vi.waitFor(() => + expect(namesOf(commands ?? null, 'command')).toEqual( + expect.arrayContaining(['deploy', 'frontend:test']) + ) + ) + }) + + it('from the folders of the host that runs it, never another host’s', async () => { + const remoteWorkspace = join(directory, 'remote-workspace') + await writeSkill(join(remoteWorkspace, '.claude', 'skills'), 'remote-only') + const remote = catalogFor(remoteWorkspace) + const record = store.getRecord(SESSION)! + remote.read(record) + await vi.waitFor(() => + expect(namesOf(remote.read(record) ?? null, 'skill')).toEqual(['remote-only']) + ) + // A chat this host does not run is answered by nothing here, not by this host's folders. + const local = catalogFor(workspace) + const elsewhere = { + ...record, + location: { ...HOST_TEST_LOCATION, executionHostId: 'ssh:box' as const } + } + local.read(elsewhere) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(local.read(elsewhere)).toBeUndefined() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.ts new file mode 100644 index 000000000000..8eccca583916 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.ts @@ -0,0 +1,9 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionSlashCommand } from '../../../shared/agent-session-wire' + +/** The `/` surface of a chat whose agent is not running, read where the provider reads its own on + * this host. `read` is undefined until known; `onChange` fires when it changes. */ +export type StructuredAgentSessionAtRestCommands = { + read(record: AgentSessionRecord): AgentSessionSlashCommand[] | undefined + onChange(listener: () => void): () => void +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts index 16af00e32ea5..361c8f73aac8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts @@ -2,9 +2,9 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AttachFlowInput } from './structured-agent-session-attach-flow' import { AgentSessionAcquisitionExitUnprovenError, - AgentSessionAcquisitionRootExitObservedError, - rethrowAfterAgentSessionAcquisitionCleanup + AgentSessionAcquisitionRootExitObservedError } from './structured-agent-session-adapter' +import { rethrowAfterAgentSessionAcquisitionCleanup } from './structured-agent-session-provider-exit-proof' export async function settlePostAcquisitionAttachFailure( input: AttachFlowInput, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts new file mode 100644 index 000000000000..11505b6c2045 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts @@ -0,0 +1,114 @@ +// The strip channel keeps one fingerprint per conversation to skip an unchanged roster; a closed +// conversation's goes with it. + +import { describe, expect, it, vi } from 'vitest' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import { StructuredAgentSessionBackgroundTaskChannel } from './structured-agent-session-background-task-channel' +import { StructuredAgentSessionConversations } from './structured-agent-session-conversations' +import type { + StructuredAgentSessionHostDeps, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' +import type { AgentSessionSubscribers } from './structured-agent-session-subscribers' + +const child: AgentChildWorkView = { + id: 'child-1', + providerId: 'task-1', + kind: 'agent', + state: 'working', + membership: 'live', + firstObservedAt: 1, + observedAt: 1, + stoppable: true, + invocation: { invocationId: 'spawn-1', generation: 1 } +} + +type Stops = { supportsTaskStop: boolean; supportsStopAll: boolean } | undefined + +function channelOver( + children: () => AgentChildWorkView[] = () => [child], + stops: () => Stops = () => ({ supportsTaskStop: true, supportsStopAll: true }) +) { + const sessions = new StructuredAgentSessionConversations({ + deliver: () => {}, + onDeliveryError: () => {}, + now: () => 1 + }) + const sent = vi.fn() + const channel = new StructuredAgentSessionBackgroundTaskChannel( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the channel reads only the record store's fence and the adapter's stop capability. + { + store: { getRecord: () => null }, + adapter: { backgroundTaskStops: stops } + } as unknown as StructuredAgentSessionHostDeps, + sessions, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: publish sends through `backgroundTasks` only. + { backgroundTasks: sent } as unknown as AgentSessionSubscribers, + async () => { + throw new Error('not opened here') + }, + children + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the map reads only the journal's commit observer and the session's provider. + const session = { + journal: { observeCommits: () => {} }, + params: { provider: 'claude' } + } as unknown as StructuredAgentSessionHostSession + return { sessions, sent, channel, session } +} + +describe('the background-task channel', () => { + it("forgets a closed conversation's roster, so reopening it sends the roster again", () => { + const { sessions, sent, channel, session } = channelOver() + sessions.set('session-1', session) + channel.publish('session-1') + channel.publish('session-1') + // An unchanged roster sends nothing. + expect(sent).toHaveBeenCalledTimes(1) + + sessions.delete('session-1') + expect(channel['published'].size).toBe(0) + sessions.set('session-1', session) + channel.publish('session-1') + expect(sent).toHaveBeenCalledTimes(2) + }) + + it('sends "no children" once, not again on every change that leaves none', () => { + let views: AgentChildWorkView[] = [] + const { sessions, sent, channel, session } = channelOver(() => views) + sessions.set('session-1', session) + channel.publish('session-1') + channel.publish('session-1') + expect(sent.mock.calls.map(([, state]) => state)).toEqual([null]) + + views = [child] + channel.publish('session-1') + views = [] + channel.publish('session-1') + channel.publish('session-1') + expect(sent.mock.calls.map(([, state]) => state?.children?.length ?? null)).toEqual([ + null, + 1, + null + ]) + }) + + // Claude's release path settles the last child after the adapter let go of the session, so no + // provider answers for it any more: the strip must still hide. + it('hides the strip when its last child settles after the provider let go of the session', () => { + let views: AgentChildWorkView[] = [child] + let held: Stops = { supportsTaskStop: true, supportsStopAll: true } + const { sessions, sent, channel, session } = channelOver( + () => views, + () => held + ) + sessions.set('session-1', session) + channel.publish('session-1') + expect(sent.mock.calls.at(-1)?.[1]?.children).toHaveLength(1) + + held = undefined + views = [{ ...child, state: 'done', membership: 'settled', outcome: 'unknown', settledAt: 2 }] + channel.publish('session-1') + expect(sent.mock.calls.at(-1)?.[1]).toBeNull() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts index 076be2340f1d..c5fd3f81b594 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts @@ -3,6 +3,9 @@ import type { AgentSessionHistoryRequest, AgentSessionHistoryResult } from '../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import { structuredChildWorkLegacyTasks } from '../../../shared/structured-agent-session-child-work-legacy' +import { structuredRunningChildWork } from '../../../shared/agent-child-work-listing' import { readStructuredAgentSessionHistoryResult } from './structured-agent-session-history-result' import { tryReadQueuePublication } from './structured-agent-session-queued-publication' import type { AgentSessionHistoryScope } from './agent-session-history-page' @@ -10,25 +13,32 @@ import type { AgentSessionSubscribers, AgentSessionSubscribeInput } from './structured-agent-session-subscribers' +import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +/** The chat strip's roster: the host's running child records for one session, the same selection + * the session list carries, with the legacy task rows an older client reads derived from them. No + * running child is `null`, and the strip hides. */ export class StructuredAgentSessionBackgroundTaskChannel { + private readonly published = new Map() + constructor( private readonly deps: StructuredAgentSessionHostDeps, - private readonly sessions: Map, + private readonly sessions: StructuredAgentSessionConversations, private readonly subscribers: AgentSessionSubscribers, /** The host's accessor: opens a conversation at rest, and never starts an agent. */ private readonly conversation: ( sessionId: string ) => Promise, - /** Task edges change the status summary too; the feed's equality check - * keeps a no-op re-projection from reaching subscribers. */ - private readonly onPublished: (sessionId: string) => void - ) {} + private readonly readChildWork: (sessionId: string) => AgentChildWorkView[] | undefined + ) { + // A closed conversation's last roster is not kept for the host's lifetime. + sessions.observeClose((sessionId) => this.published.delete(sessionId)) + } /** `scope` is for in-process readers; a wire request reads every agent's rows. */ async history( @@ -72,20 +82,51 @@ export class StructuredAgentSessionBackgroundTaskChannel { }) } - publish(sessionId: string, publishedState?: AgentSessionBackgroundTaskState | null): void { + /** Re-read after the session's child records changed; an unchanged roster sends nothing. */ + publish(sessionId: string): void { const session = this.sessions.get(sessionId) - const state = publishedState !== undefined ? publishedState : this.state(sessionId) - if (session && state !== undefined) { - this.subscribers.backgroundTasks( - sessionId, - state, - structuredAgentSessionConversationFence(this.deps.store, sessionId) - ) - this.onPublished(sessionId) + // Explicit null once a roster was sent, not silence: a reader keeps its last roster on + // `undefined`, and a closing provider stops answering before its records are gone. + const read = this.state(sessionId) + const state = read === undefined && this.published.has(sessionId) ? null : read + if (!session || state === undefined) { + return } + const fingerprint = JSON.stringify(state) + if (this.published.get(sessionId) === fingerprint) { + return + } + // "None" is remembered too, so a session with no children sends it once, not on every change; + // the entry goes when the conversation closes. + this.published.set(sessionId, fingerprint) + this.subscribers.backgroundTasks( + sessionId, + state, + structuredAgentSessionConversationFence(this.deps.store, sessionId) + ) } private state(sessionId: string): AgentSessionBackgroundTaskState | null | undefined { - return this.deps.adapter.backgroundTaskState?.(sessionId) + const session = this.sessions.get(sessionId) + const stored = session ? this.readChildWork(sessionId) : undefined + if (!session || stored === undefined) { + return undefined + } + const views = structuredRunningChildWork(stored) + const stops = this.deps.adapter.backgroundTaskStops?.(sessionId) + if (views.length === 0) { + // As before: a session no live provider holds says nothing, a live one says "none". + return stops === undefined ? undefined : null + } + const { tasks, settledTasks } = structuredChildWorkLegacyTasks(views, session.params.provider) + return { + state: 'monitoring', + ...(tasks ? { tasks } : {}), + ...(settledTasks ? { settledTasks } : {}), + ...(stops?.supportsTaskStop ? { supportsTaskStop: true } : {}), + // A session no live provider holds has nothing a stop could reach. + ...(stops?.supportsStopAll ? {} : { supportsStopAll: false }), + children: views + } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts new file mode 100644 index 000000000000..af618040fa3a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -0,0 +1,116 @@ +// The chat's Stop, however a client reached it: the Stop button or a question card's Cancel. One +// body and one order: withdraw what is queued, record where the Stop took effect, interrupt, then +// end the child in the next step on the session's lane. The body is reachable only through +// `mutateWithChatStop`, which queues that step in the same synchronous call as the mutation. + +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import type { + AgentSessionCancelResult, + AgentSessionMutationEnvelope, + AgentSessionMutationResult +} from '../../../shared/agent-session-wire' +import { + mutateStructuredAgentSession, + type StructuredAgentSessionMutationContext +} from './structured-agent-session-mutation-context' +import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' +import type { MutationPlan } from './structured-agent-session-mutation-plans' +import { runStopWithQueuePause } from './structured-agent-session-queued-stop' +import { + openForWrite, + structuredAgentSessionFailureWordsContext +} from './structured-agent-session-send-preparation' +import { + endStoppedStructuredAgentSession, + isMainAgentWorkingOnceFlushed, + performCancel, + type StructuredAgentSessionStopWindDown +} from './structured-agent-session-turns-cancel' +import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' + +type ChatStopOutcome = TurnOutcome + +/** What the chat's Stop did, and whether its next step ends the provider's session. */ +export type StructuredAgentSessionChatStopRun = { outcome: ChatStopOutcome; endsSession: boolean } + +/** Runs `plan` with `run`, which may call `stop` for the chat's Stop. */ +export function mutateWithChatStop( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + params: { envelope: AgentSessionMutationEnvelope; turnId?: string }, + plan: MutationPlan, + run: ( + ctx: AgentSessionTurnContext, + stop: () => Promise + ) => Promise> +): Promise> { + const { envelope, turnId } = params + const { sessionId } = envelope + // Set by the Stop's step only when its provider's session ends; a replay leaves it unset. + let windDown: StructuredAgentSessionStopWindDown | undefined + const named = turnId !== undefined ? { turnId } : {} + // Stop's queue step, the same for every client: once the Stop takes effect the queue is paused. + // The cards stay published; nothing is withdrawn and no text ever rides the answer. + const stop = (ctx: AgentSessionTurnContext): Promise => + runStopWithQueuePause(ctx, async (tookEffect) => { + // Stop withdraws every queued SUBMISSION first, whatever the start or the child is doing. + const withdrawn = await ctx.journal.rejectQueuedSubmissions( + ctx.fence, + agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + ) + const child = context.sessions.get(ctx.sessionId)?.child + if (child?.phase === 'starting') { + // A start that may never land is the one thing here Stop has to end; the chat stays. + await tookEffect() + await context.stopAgent(ctx.sessionId) + return { ok: true, value: { ...named, cancelled: true } } + } + // A Stop naming no turn ends nothing more unless the session reads working, by the rule + // every session list and the chat's own Stop read it. + const inFlight = turnId !== undefined || (await isMainAgentWorkingOnceFlushed(ctx)) + const record = context.deps.store.getRecord(ctx.sessionId) + if (!child || !inFlight) { + if (withdrawn.length > 0) { + await tookEffect() + } + return { ok: true, value: { ...named, cancelled: withdrawn.length > 0 } } + } + await tookEffect() + return performCancel( + { ...ctx, failureTextContext: structuredAgentSessionFailureWordsContext(record) }, + { + clientOperationId: envelope.clientOperationId, + ...named, + stopChild: () => context.stopAgent(sessionId), + endSession: (owed) => { + windDown = owed + }, + withdrewQueued: withdrawn.length > 0 + } + ) + }) + const result = mutateStructuredAgentSession( + context, + caller, + envelope, + { + ...plan, + run: (ctx) => + run(ctx, async () => ({ outcome: await stop(ctx), endsSession: windDown !== undefined })) + }, + openForWrite(context, envelope) + ) + // Queued in the mutation's own tick, so a send made meanwhile lands behind the child's end. + void context.serialize(sessionId, async () => { + if (windDown) { + await endStoppedStructuredAgentSession( + { sessionId, adapter: context.deps.adapter }, + windDown, + () => context.stopAgent(sessionId), + (error) => context.deps.onEventSinkError?.({ sessionId, error }) + ) + } + }) + return result +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-work-selection.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-work-selection.test.ts new file mode 100644 index 000000000000..32b4abf5a215 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-work-selection.test.ts @@ -0,0 +1,85 @@ +// What the sidebar and the chat's strip list of a session's child records: running children only, +// by one rule. A finished child is in the transcript, not in either list. + +import { describe, expect, it } from 'vitest' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import { structuredRunningChildWork } from '../../../shared/agent-child-work-listing' +import { + attach, + hostTestState, + serveHostTestChildWork +} from './structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' + +function running(id: string, over: Partial = {}): AgentChildWorkView { + return { + id, + kind: 'agent', + state: 'working', + membership: 'live', + firstObservedAt: 1, + observedAt: 1, + stoppable: false, + invocation: { invocationId: `spawn-${id}`, generation: 1 }, + ...over + } +} + +function finished(id: string, settledAt: number): AgentChildWorkView { + return running(id, { + state: 'done', + membership: 'settled', + outcome: 'succeeded', + observedAt: settledAt, + settledAt + }) +} + +const ids = (views: readonly AgentChildWorkView[]) => views.map((view) => view.id) + +describe('the sidebar and the strip list running children only', () => { + it('drops finished and failed children, and keeps a finished one whose shell still runs', () => { + const failed = { ...finished('failed', 5), outcome: 'failed' as const } + const views = [ + running('working'), + finished('done', 4), + failed, + finished('owner', 3), + running('shell', { kind: 'command', parentChildWorkId: 'owner' }) + ] + expect(ids(structuredRunningChildWork(views))).toEqual(['working', 'owner', 'shell']) + }) +}) + +describe("the host's strip channel and summary list the same running children", () => { + it('sends both the running children, and the strip nothing once none runs', async () => { + let records = [ + finished('done', 5), + running('run'), + finished('owner', 6), + running('shell', { kind: 'command', parentChildWorkId: 'owner' }) + ] + serveHostTestChildWork(() => records) + await attach() + const { host } = hostTestState() + const strip = async () => { + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + return page.ok ? page.page.backgroundTasks : undefined + } + expect(ids((await strip())?.children ?? [])).toEqual(['run', 'owner', 'shell']) + const summaries: string[][] = [] + host.subscribeStatus({ + id: 'list', + emit: (event) => { + if (event.type === 'snapshot') { + summaries.push(event.sessions.flatMap((session) => ids(session.children ?? []))) + } + } + }) + expect(summaries.at(-1)).toEqual(['run', 'owner', 'shell']) + + // Nothing runs: no strip at all (a host that holds no provider says nothing). + records = [finished('done', 5), finished('run', 7)] + expect(await strip()).toBeUndefined() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts index b456bacac26e..041b6580ea21 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts @@ -208,38 +208,34 @@ it('ends a hung /compact Claude will not interrupt by stopping it, and answers t }) }) -it('ends a stopped /compact on its own interrupted result, and a late copy of that result does not end the next one', async () => { +it('ends a stopped /compact on its own interrupted result, then ends its child; a late copy never reaches the next one', async () => { claude.routes.interrupt = () => ({}) const first = await compact() const { connection, uuid: firstUuid } = await sent('/compact') await expect(stop(first)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) - // The interrupt was taken: the command runs until Claude answers it. - expect((await commandState(first))?.state).toBe('running') + // The interrupt was taken: the child waits for Claude to end the command itself. + expect(connection.closed).toBe(false) frame(connection, result(firstUuid, INTERRUPTED)) - await vi.waitFor(async () => - expect(await commandState(first)).toMatchObject({ - state: 'interrupted', - outcome: 'cancellation' - }) - ) + await vi.waitFor(() => expect(connection.closed).toBe(true)) + expect(await commandState(first)).toMatchObject({ + state: 'interrupted', + outcome: 'cancellation' + }) const second = await compact() - await vi.waitFor(() => - expect( - connection.sent.filter((message) => JSON.stringify(message).includes('/compact')) - ).toHaveLength(2) - ) - const secondUuid = String(connection.sent.at(-1)?.uuid) + const next = await vi.waitFor(() => { + const started = claude.connections.at(-1) + expect(started).not.toBe(connection) + expect(started?.sent.some((message) => JSON.stringify(message).includes('/compact'))).toBe(true) + return started! + }) + const secondUuid = String(next.sent.at(-1)?.uuid) frame(connection, result(firstUuid, INTERRUPTED)) - frame( - connection, - result('an-earlier-send', { subtype: 'error_during_execution', is_error: true }) - ) expect((await commandState(second))?.state).toBe('running') - frame(connection, { type: 'system', subtype: 'compact_boundary', uuid: 'boundary' }) - frame(connection, result(secondUuid)) + frame(next, { type: 'system', subtype: 'compact_boundary', uuid: 'boundary' }) + frame(next, result(secondUuid)) await vi.waitFor(async () => expect(await commandState(second)).toMatchObject({ state: 'completed', outcome: 'success' }) ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts index 7cfd7fe86234..778bd3cc2fa9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts @@ -119,7 +119,8 @@ describe('Claude root-exit stop', () => { const deps = { store, adapter, - journalDatabase: openTestJournalHostDatabase(root), + // The one database the store and the journal share, as the runtime installs them. + journalDatabase: openTestJournalHostDatabase(stateDirectory), claimKeyId: 'key-1' } const runtimeState = new StructuredAgentSessionHostRuntimeState(deps) @@ -134,7 +135,8 @@ describe('Claude root-exit stop', () => { now: () => NOW + 30 * 60_000, publishStatus }, - 'session-1' + 'session-1', + { cause: 'evict' } ) ).resolves.toBeUndefined() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts new file mode 100644 index 000000000000..ad314376b1a5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts @@ -0,0 +1,870 @@ +// A Stop ends Claude's child on the shipping adapter, whatever Claude answered the interrupt. The +// Stop answers on the interrupt; its next serialized step ends the child once Claude has wound down +// what it had in flight, or the grace runs out. The chat rests; the next send resumes it. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' +import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' +import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' +import type { AgentStatusStructuredSessionSubject } from '../../../shared/agent-status-subject' +import { AgentHookServer } from '../../agent-hooks/server' +import { + ClaudeControlRequestError, + runClaudeControl +} from '../../claude/claude-agent-sdk-control-requests' +import { CLAUDE_STOP_GRACE_MS } from '../../claude/claude-request-end-wait' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import type { ClaudeStructuredSessionEvent } from '../../claude/claude-structured-session-state' +import { + fakeClaude, + PROVIDER_SESSION_ID, + type FakeConnection +} from '../../claude/claude-structured-session-test-support' +import { invokeCanUseTool } from '../../claude/claude-can-use-tool-test-support' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } +// As Claude Code 2.1.280 advertises them on a turn's system/init frame. +const CAPABILITIES = ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1', 'msg_lifecycle_v1'] + +let root: string +let host: StructuredAgentSessionHost +let adapter: ClaudeStructuredSessionAdapter +let store: AgentSessionRecordStore +let queued: string[] +let claude: ReturnType +let events: ClaudeStructuredSessionEvent[] +let sinkErrors: unknown[] +// The host's status row and child records, as the app's hook server holds them. +let server: AgentHookServer +let statusSubject: AgentStatusStructuredSessionSubject | undefined + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-stop-ends-session-')) + resetHostTestOperationIds() + queued = [] + events = [] + sinkErrors = [] + server = new AgentHookServer() + statusSubject = undefined + claude = fakeClaude({ + replayUuid: null, + routes: { + interrupt: (params) => + params?.cancelQueued + ? { still_queued: [], cancelled: queued.splice(0) } + : { still_queued: [...queued] } + } + }) + const lifecycle: Promise[] = [] + adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: root, + claudeConfigDir: join(root, 'claude-home'), + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0, + continuesChain: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0 + }), + onEvent: (event) => { + events.push(event) + const mapped = structuredClaudeLifecycleEvent(event) + if (mapped) { + lifecycle.push(host.handleAdapterEvent(mapped)) + } + }, + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + onChildWorkEvidence: (sessionId, evidence) => + host.publishChildWorkEvidence(sessionId, evidence), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => NOW + }) + store = await openTestAgentSessionRecordStore(root) + host = new StructuredAgentSessionHost({ + store, + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + onEventSinkError: ({ error }) => sinkErrors.push(error), + statusSink: { + publish: (summary, subject) => { + statusSubject = subject + server.ingestStructuredStatus(summary, subject) + }, + forget: (subject) => server.dropStructuredStatus(subject), + publishChildWork: (subject, evidence, provider) => + server.ingestStructuredChildWork(subject, evidence, provider), + readChildWork: (subject) => server.getStructuredChildWorkViews(subject) + }, + now: () => NOW + }) + const params = hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + }) + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + await adapter.awaitStarted(SESSION) + await Promise.all(lifecycle) +}) + +afterEach(async () => { + await adapter.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function eventually(assertion: () => T | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +function envelope( + method: 'agentSession.send' | 'agentSession.cancel', + fields: Record, + fence = store.getRecord(SESSION)!.lease.runtimeFence +) { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +async function send(text: string, fence?: number): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body }, fence), + body + }) + if (!sent.ok) { + throw new Error('send refused') + } + return sent.value.clientMessageId +} + +async function dispatch(clientMessageId: string) { + const submission = (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) + return { state: submission?.dispatchState, reason: submission?.reason } +} + +function frame(connection: FakeConnection, message: Record): void { + connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) +} + +/** Sends a message and lets Claude open its turn and write one reply; returns the turn's id. */ +async function openTurn(connection: FakeConnection, text = 'Write a long reply.'): Promise { + const clientMessageId = await send(text) + await eventually(() => + expect(connection.sent.some((message) => JSON.stringify(message).includes(text))).toBe(true) + ) + frame(connection, { + type: 'system', + subtype: 'init', + uuid: `init-${text}`, + model: 'claude-sonnet-5', + capabilities: CAPABILITIES + }) + const written = connection.sent.at(-1)! + frame(connection, { ...written, uuid: written.uuid }) + frame(connection, { + type: 'assistant', + uuid: 'stopped-turn-leaf', + parent_tool_use_id: null, + message: { id: 'msg-1', role: 'assistant', content: [{ type: 'text', text: 'Working on' }] } + }) + await eventually(async () => expect((await dispatch(clientMessageId)).state).toBe('accepted')) + const turnId = activeStructuredAgentSessionTurnId((await host.journalSnapshot(SESSION)).items) + expect(turnId).not.toBeNull() + return turnId! +} + +function stop(turnId?: string) { + const fields = turnId === undefined ? {} : { turnId } + return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', fields), ...fields }) +} + +/** Resolves once everything queued on the session's lane so far has run: a Stop's second step. */ +function laneDrained(): Promise { + return host['tasks'].serialize(SESSION, async () => {}) +} + +function wrote(connection: FakeConnection, text: string): boolean { + return connection.sent.some((message) => JSON.stringify(message).includes(text)) +} + +const INTERRUPTED_RESULT = { + type: 'result', + subtype: 'error_during_execution', + is_error: true, + terminal_reason: 'aborted_streaming', + uuid: 'interrupted-result' +} + +// As the real control surface runs it: no answer ever comes, only the deadline Orca sets. +const NEVER_ANSWERS = (options?: Record) => + runClaudeControl( + 'interrupt', + () => new Promise(() => {}), + typeof options?.timeoutMs === 'number' ? options.timeoutMs : undefined + ) + +async function interruptSent(connection: FakeConnection): Promise { + await eventually(() => + expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(true) + ) +} + +async function turnOutcome(): Promise { + await host.flushStreamedEvents(SESSION) + const snapshot = await host.journalSnapshot(SESSION) + return readAgentJournalTurn(snapshot.items.findLast((item) => item.body.kind === 'turn')?.body) + ?.outcome +} + +async function statusTexts(): Promise { + await host.flushStreamedEvents(SESSION) + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [String(item.body.text)] : [] + ) +} + +it('answers on the interrupt, ends the child once the stopped turn ends, and rests at that turn', async () => { + const connection = claude.connections[0]! + await openTurn(connection) + + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + // The Stop answered on the interrupt Claude took: the child waits for Claude to end the turn. + expect(connection.closed).toBe(false) + expect(await statusTexts()).toEqual(['Cancellation requested.']) + const ended = Date.now() + frame(connection, INTERRUPTED_RESULT) + await laneDrained() + // The turn's own end releases the wait; the grace is not waited out. + expect(Date.now() - ended).toBeLessThan(CLAUDE_STOP_GRACE_MS / 2) + + expect(connection.closed).toBe(true) + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + expect(await turnOutcome()).toBe('cancellation') + // The resume point is the stopped turn's own, so the next send continues after it. + expect(events.findLast((event) => event.type === 'handle')).toMatchObject({ + type: 'handle', + providerSessionId: PROVIDER_SESSION_ID, + leafUuid: 'stopped-turn-leaf' + }) +}) + +/** Sends a message Claude takes but has not echoed yet: no turn row, the chat still working. */ +async function sendUnechoed(connection: FakeConnection): Promise { + const clientMessageId = await send('Write a long reply.') + await eventually(() => expect(wrote(connection, 'Write a long reply.')).toBe(true)) + return clientMessageId +} + +function childRecords() { + return statusSubject ? server.getStructuredChildWorkViews(statusSubject) : [] +} + +async function agentStatus() { + await host.flushStreamedEvents(SESSION) + const { items, submissions } = await host.journalSnapshot(SESSION) + const { summary } = projectStructuredAgentSessionStatusState( + items, + submissions, + store.getRecord(SESSION)!.lease.runtimeFence + ) + return summary.status + ? structuredAgentSessionAgentStatus({ + status: summary.status, + turnOutcome: summary.turnOutcome, + childWork: childRecords() + }) + : null +} + +it('reads a Stop pressed before Claude echoed the send as interrupted, not as a finished turn', async () => { + const connection = claude.connections[0]! + // As Claude winds down a request interrupted before its echo: echo, marker, aborted result, idle. + claude.routes.interrupt = () => { + setTimeout(() => { + const written = connection.sent.find((message) => message.type === 'user')! + frame(connection, { ...written, uuid: written.uuid }) + frame(connection, { + type: 'user', + message: { + role: 'user', + content: [{ type: 'text', text: '[Request interrupted by user]' }] + }, + parent_tool_use_id: null, + uuid: 'interrupted-marker' + }) + frame(connection, INTERRUPTED_RESULT) + frame(connection, { type: 'system', subtype: 'session_state_changed', state: 'idle' }) + }, 5) + return { still_queued: [], cancelled: [] } + } + const clientMessageId = await sendUnechoed(connection) + + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() + + expect(connection.closed).toBe(true) + expect(await dispatch(clientMessageId)).toMatchObject({ state: 'accepted' }) + expect(await turnOutcome()).toBe('cancellation') + expect(await agentStatus()).toMatchObject({ mainAgent: { outcome: 'cancellation' } }) +}) + +it('ends the child once the grace runs out when Claude says nothing after a Stop before the echo', async () => { + const connection = claude.connections[0]! + claude.routes.interrupt = () => ({ still_queued: [], cancelled: [] }) + const clientMessageId = await sendUnechoed(connection) + + const asked = Date.now() + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() + + // As before the wait: the send Claude never answered is doubt once its child ends. + expect(connection.closed).toBe(true) + expect(Date.now() - asked).toBeLessThan(CLAUDE_STOP_GRACE_MS + 1_500) + expect(await dispatch(clientMessageId)).toMatchObject({ state: 'unknown' }) +}, 15_000) + +it('withdraws a follow-up Claude queued behind the turn before the child ends, never doubt', async () => { + const connection = claude.connections[0]! + await openTurn(connection) + const followUp = await send('And then this.') + await eventually(() => expect(connection.sent.at(-1)?.message).toBeDefined()) + queued.push(String(connection.sent.at(-1)!.uuid)) + await eventually(async () => expect((await dispatch(followUp)).state).toBe('pending')) + + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + + // Withdrawn by Claude's own receipt while the child still runs, so it is never re-sent and never + // read as delivered. + expect(connection.closed).toBe(false) + expect(await dispatch(followUp)).toEqual({ + state: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + }) + frame(connection, INTERRUPTED_RESULT) + await laneDrained() + expect(connection.closed).toBe(true) +}) + +it('ends the child at once when Claude refuses the interrupt, and says only that the stop was asked', async () => { + claude.routes.interrupt = () => { + throw new ClaudeControlRequestError('interrupt', 'Claude did not answer the interrupt.') + } + const connection = claude.connections[0]! + await openTurn(connection) + + const asked = Date.now() + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() + // A turn Claude would not interrupt ends only with its child, so there is no grace to wait. + expect(Date.now() - asked).toBeLessThan(CLAUDE_STOP_GRACE_MS / 2) + + expect(connection.closed).toBe(true) + expect(await turnOutcome()).toBe('cancellation') + const texts = await statusTexts() + expect(texts).toContain('Cancellation requested.') + expect(texts.some((text) => text.includes("didn't stop"))).toBe(false) +}) + +it('ends the child when the interrupt fails, with no unconfirmed row', async () => { + claude.routes.interrupt = () => { + throw new Error('control request lost') + } + const connection = claude.connections[0]! + await openTurn(connection) + + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() + + expect(connection.closed).toBe(true) + expect(await turnOutcome()).toBe('cancellation') + expect(await statusTexts()).toEqual(['Cancellation requested.']) +}) + +it('ends the child within the grace when Claude never answers the interrupt', async () => { + claude.routes.interrupt = NEVER_ANSWERS + const connection = claude.connections[0]! + await openTurn(connection) + + const asked = Date.now() + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() + + expect(connection.closed).toBe(true) + expect(Date.now() - asked).toBeLessThan(CLAUDE_STOP_GRACE_MS + 1_500) + expect(await turnOutcome()).toBe('cancellation') + expect(await statusTexts()).toEqual(['Cancellation requested.']) +}, 15_000) + +it('ends background work Claude runs when the Stop ends the child', async () => { + const connection = claude.connections[0]! + await openTurn(connection) + frame(connection, { + type: 'system', + subtype: 'task_started', + uuid: 'task-start', + task_id: 'background-1', + task_type: 'local_agent', + is_backgrounded: true + }) + const background = { providerId: 'background-1', kind: 'agent' } + // The host's child record, which the strip, the sidebar and Monitoring all read. + expect(childRecords()).toEqual([ + expect.objectContaining({ ...background, state: 'working', membership: 'live' }) + ]) + + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + frame(connection, INTERRUPTED_RESULT) + await laneDrained() + // The work ran inside Claude's process, so it ends with it: its record settles, and the chat + // reads done, Interrupted, with nothing left for Monitoring. + expect(childRecords()).toEqual([ + expect.objectContaining({ + ...background, + state: 'done', + membership: 'settled', + // Stopped with the chat, as a task's own stop reads: Interrupted, not an unknown ending. + outcome: 'cancelled' + }) + ]) + expect(await agentStatus()).toEqual({ + state: 'done', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + expect(connection.closed).toBe(true) +}) + +it('starts a new child for the next send after a Stop, on the same Claude conversation', async () => { + const connection = claude.connections[0]! + await openTurn(connection) + await stop() + frame(connection, INTERRUPTED_RESULT) + await laneDrained() + + const next = await send('Carry on.') + const resumed = await eventually(() => { + const started = claude.connections.at(-1) + expect(started).not.toBe(connection) + expect(started && wrote(started, 'Carry on.')).toBe(true) + return started! + }) + // The wake resumes the same Claude conversation; the first test pins the leaf it resumes after. + expect(store.getRecord(SESSION)?.providerHandleChain.at(-1)?.handle).toMatchObject({ + sessionId: PROVIDER_SESSION_ID + }) + expect(resumed.closed).toBe(false) + expect(await dispatch(next)).toMatchObject({ state: 'pending' }) +}) + +it('delivers a send issued with the pre-Stop fence during the Stop to the resumed child only', async () => { + const connection = claude.connections[0]! + await openTurn(connection) + const fence = store.getRecord(SESSION)!.lease.runtimeFence + let childrenWhenClosed: number | undefined + const close = connection.close + connection.close = async () => { + const proven = await close() + childrenWhenClosed = claude.connections.length + return proven + } + + let answer!: () => void + claude.routes.interrupt = () => + new Promise((resolve) => { + answer = () => resolve({ still_queued: [], cancelled: [] }) + }) + + // Issued while the Stop's first step waits on the interrupt: the client still holds the fence + // the rest moves. + const stopped = stop() + await interruptSent(connection) + const sent = send('Typed during the Stop.', fence) + answer() + expect(await stopped).toMatchObject({ ok: true, value: { cancelled: true } }) + frame(connection, INTERRUPTED_RESULT) + + await expect(sent).resolves.toEqual(expect.any(String)) + const resumed = await eventually(() => { + const started = claude.connections.at(-1)! + expect(started).not.toBe(connection) + expect(wrote(started, 'Typed during the Stop.')).toBe(true) + return started + }) + // Handed over only after the old child's close resolved, and never to that child. + expect(childrenWhenClosed).toBe(1) + expect(resumed.closed).toBe(false) + expect(wrote(connection, 'Typed during the Stop.')).toBe(false) + expect(store.getRecord(SESSION)!.lease.runtimeFence).not.toBe(fence) + expect((await statusTexts()).filter((text) => text !== 'Cancellation requested.')).toEqual([]) +}) + +it('sends a queue-if-active message issued while the Stop ends the child directly to the resumed child', async () => { + const connection = claude.connections[0]! + await openTurn(connection) + const fence = store.getRecord(SESSION)!.lease.runtimeFence + let childrenWhenClosed: number | undefined + const close = connection.close + connection.close = async () => { + const proven = await close() + childrenWhenClosed = claude.connections.length + return proven + } + + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + // Issued while the Stop's second step waits for the stopped turn, with the fence it moves. + const body = hostTestMessage('Queued during the Stop.') + const sent = host.send(CALLER, { + envelope: envelope('agentSession.send', { body, delivery: 'queue-if-active' }, fence), + body, + delivery: 'queue-if-active', + userSend: true + }) + frame(connection, INTERRUPTED_RESULT) + + // Nothing runs after the rest, so it goes out as a direct submission, not a queued card. + expect(await sent).toMatchObject({ ok: true, value: { submission: expect.any(Object) } }) + await eventually(() => { + const started = claude.connections.at(-1)! + expect(started).not.toBe(connection) + expect(wrote(started, 'Queued during the Stop.')).toBe(true) + }) + expect(childrenWhenClosed).toBe(1) + expect(wrote(connection, 'Queued during the Stop.')).toBe(false) +}) + +it("runs nothing queued during the Stop's first step before the child's end", async () => { + const connection = claude.connections[0]! + await openTurn(connection) + let answer!: () => void + claude.routes.interrupt = () => + new Promise((resolve) => { + answer = () => resolve({ still_queued: [], cancelled: [] }) + }) + + const stopped = stop() + await interruptSent(connection) + // Any later operation on the chat, such as a prompt answer or an option change, queues here. + let childLiveForNextOperation: boolean | undefined + const next = host['tasks'].serialize(SESSION, async () => { + childLiveForNextOperation = !connection.closed + }) + answer() + await stopped + frame(connection, INTERRUPTED_RESULT) + await next + + expect(childLiveForNextOperation).toBe(false) +}) + +it('still answers the Stop, with its row, when the child cannot be proven gone; the failure is reported', async () => { + const connection = claude.connections[0]! + await openTurn(connection) + const close = connection.close + connection.close = async () => false + + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + frame(connection, INTERRUPTED_RESULT) + await laneDrained() + + expect(await statusTexts()).toEqual(['Cancellation requested.']) + expect(sinkErrors).toEqual([ + expect.objectContaining({ + name: 'StructuredAgentSessionEvictionError', + step: 'stop-provider-child' + }) + ]) + connection.close = close +}) + +it.each([ + ['takes', undefined], + [ + 'fails', + () => { + throw new Error('control request lost') + } + ], + ['never answers', NEVER_ANSWERS] +])( + 'ends the child for a Stop naming the turn that just ended when Claude interrupts the follow-up and %s', + async (_answer, interrupt) => { + if (interrupt) { + claude.routes.interrupt = interrupt + } + const connection = claude.connections[0]! + const ended = await openTurn(connection) + frame(connection, { type: 'result', subtype: 'success', is_error: false, uuid: 'result-1' }) + await eventually(async () => + expect( + activeStructuredAgentSessionTurnId((await host.journalSnapshot(SESSION)).items) + ).toBeNull() + ) + // Handed over but not yet echoed: no turn of its own for a client to name. + await send('Follow-up.') + await eventually(() => expect(wrote(connection, 'Follow-up.')).toBe(true)) + + // As the phone sends it: the turn it last saw working. + await expect(stop(ended)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() + + expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(true) + expect(connection.closed).toBe(true) + expect(await statusTexts()).toEqual(['Cancellation requested.']) + }, + 15_000 +) + +it('keeps a second Stop pressed while the first ends the child quiet', async () => { + const connection = claude.connections[0]! + await openTurn(connection) + + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + const second = stop() + frame(connection, INTERRUPTED_RESULT) + + await expect(second).resolves.toMatchObject({ ok: true, value: { cancelled: false } }) + expect(connection.closed).toBe(true) + expect(connection.calls.filter((call) => call.subtype === 'interrupt')).toHaveLength(1) + expect(await statusTexts()).toEqual(['Cancellation requested.']) + expect(sinkErrors).toEqual([]) +}) + +const BRANCH_QUESTION = { + questions: [ + { + question: 'Which branch?', + header: 'Branch', + multiSelect: false, + options: [{ label: 'main' }, { label: 'dev' }] + } + ] +} + +/** Claude asks on the open turn; returns its answer and the card the journal shows for it. */ +async function ask( + connection: FakeConnection, + toolName: string, + input: Record, + signal?: AbortSignal +): Promise<{ + answered: ReturnType + card: { itemId: string; expectedRevision: number } +}> { + const answered = invokeCanUseTool(connection, toolName, 'permission-1', 'tool-1', { + input, + ...(signal ? { signal } : {}) + }) + const card = await eventually(async () => { + await host.flushStreamedEvents(SESSION) + const item = (await host.journalSnapshot(SESSION)).items.find( + (entry) => entry.body.kind === 'approval' || entry.body.kind === 'question' + ) + expect(item).toBeDefined() + return { itemId: item!.itemId, expectedRevision: item!.revision } + }) + return { answered, card } +} + +function cancelCard(turnId: string, prompt: { itemId: string; expectedRevision: number }) { + const fields = { turnId, prompt } + return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', fields), ...fields }) +} + +async function cardResolution(itemId: string): Promise { + await host.flushStreamedEvents(SESSION) + const body = (await host.journalSnapshot(SESSION)).items.find( + (item) => item.itemId === itemId + )?.body + return body?.kind === 'approval' || body?.kind === 'question' ? body.resolution : undefined +} + +it('dismisses an approval card on its Cancel with the Deny reply, and the turn and child go on', async () => { + const connection = claude.connections[0]! + const turnId = await openTurn(connection) + const { answered, card } = await ask(connection, 'Bash', { command: 'rm -rf build' }) + + await expect(cancelCard(turnId, card)).resolves.toMatchObject({ + ok: true, + value: { turnId, cancelled: true } + }) + await laneDrained() + + const reply = await answered.promise + expect(reply).toMatchObject({ behavior: 'deny', message: 'User denied this action.' }) + expect(reply).not.toHaveProperty('interrupt') + // Read as cancelled by the user, as every card's Cancel reads, not as a Deny pressed. + expect(await cardResolution(card.itemId)).toMatchObject({ + state: 'cancelled', + resolvedBy: CALLER.callerKey + }) + expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(false) + expect(connection.closed).toBe(false) + expect(activeStructuredAgentSessionTurnId((await host.journalSnapshot(SESSION)).items)).toBe( + turnId + ) + expect(await statusTexts()).toEqual([]) +}) + +it("ends a question card's Cancel the way the chat's Stop does, and the next send resumes", async () => { + const connection = claude.connections[0]! + const turnId = await openTurn(connection) + const request = new AbortController() + const { answered, card } = await ask( + connection, + 'AskUserQuestion', + BRANCH_QUESTION, + request.signal + ) + + await expect(cancelCard(turnId, card)).resolves.toMatchObject({ + ok: true, + value: { turnId, cancelled: true } + }) + // As Claude does once interrupted: it cancels the request it was holding. + request.abort() + await host.flushStreamedEvents(SESSION) + // Settled with the Stop's first step, before the child ends: not answerable meanwhile. + expect(connection.closed).toBe(false) + const cancelledHere = { state: 'cancelled', resolvedBy: CALLER.callerKey } + expect(await cardResolution(card.itemId)).toMatchObject(cancelledHere) + expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(true) + frame(connection, INTERRUPTED_RESULT) + await laneDrained() + + expect(connection.closed).toBe(true) + expect(await turnOutcome()).toBe('cancellation') + // The child's end takes Claude's request with it: no reply raced the interrupt, and nothing + // wrote over the user's cancel. + expect(await answered.promise).not.toMatchObject({ behavior: expect.any(String) }) + expect(await cardResolution(card.itemId)).toMatchObject(cancelledHere) + expect(await statusTexts()).toEqual(['Cancellation requested.']) + await send('Carry on.') + await eventually(() => { + const started = claude.connections.at(-1)! + expect(started).not.toBe(connection) + expect(wrote(started, 'Carry on.')).toBe(true) + }) +}) + +it("declines a question card's Cancel itself when the Stop finds nothing to stop", async () => { + const connection = claude.connections[0]! + const ended = await openTurn(connection) + frame(connection, { type: 'result', subtype: 'success', is_error: false, uuid: 'result-1' }) + await eventually(async () => + expect( + activeStructuredAgentSessionTurnId((await host.journalSnapshot(SESSION)).items) + ).toBeNull() + ) + // Asked after the main turn ended, as a background agent might. + const { answered, card } = await ask(connection, 'AskUserQuestion', BRANCH_QUESTION) + + await expect(cancelCard(ended, card)).resolves.toMatchObject({ ok: true }) + await laneDrained() + + expect(await answered.promise).toMatchObject({ + behavior: 'deny', + message: expect.stringMatching(/dismissed/) + }) + expect(await cardResolution(card.itemId)).toMatchObject({ + state: 'cancelled', + resolvedBy: CALLER.callerKey + }) + expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(false) + expect(connection.closed).toBe(false) + expect(await statusTexts()).toEqual([]) +}) + +it('dismisses a question card a finished turn raised, leaving the turn running now alone', async () => { + const connection = claude.connections[0]! + const earlier = await openTurn(connection) + // Asked in the first turn, then outlived it, as a background agent's question might. + const { answered, card } = await ask(connection, 'AskUserQuestion', BRANCH_QUESTION) + frame(connection, { type: 'result', subtype: 'success', is_error: false, uuid: 'result-1' }) + await eventually(async () => + expect( + activeStructuredAgentSessionTurnId((await host.journalSnapshot(SESSION)).items) + ).toBeNull() + ) + const running = await openTurn(connection, 'Now something else.') + expect(running).not.toBe(earlier) + + await expect(cancelCard(earlier, card)).resolves.toMatchObject({ ok: true }) + await laneDrained() + + expect(await answered.promise).toMatchObject({ behavior: 'deny' }) + expect(await cardResolution(card.itemId)).toMatchObject({ + state: 'cancelled', + resolvedBy: CALLER.callerKey + }) + expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(false) + expect(connection.closed).toBe(false) + expect(activeStructuredAgentSessionTurnId((await host.journalSnapshot(SESSION)).items)).toBe( + running + ) +}) + +it('dismisses a plan card on its Cancel: Claude is told to wait for the user, and keeps running', async () => { + const connection = claude.connections[0]! + const turnId = await openTurn(connection) + const { answered, card } = await ask(connection, 'ExitPlanMode', { + plan: '# Release\n\n- Tag it' + }) + + await expect(cancelCard(turnId, card)).resolves.toMatchObject({ + ok: true, + value: { turnId, cancelled: true } + }) + await laneDrained() + + const reply = await answered.promise + expect(reply).toMatchObject({ behavior: 'deny' }) + expect(reply).not.toHaveProperty('interrupt') + // Not "keep planning": nothing asks Claude to revise and show another plan. + expect(JSON.stringify(reply)).toMatch(/wait for them/) + expect(JSON.stringify(reply)).not.toMatch(/keep planning|ExitPlanMode again/i) + expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(false) + expect(connection.closed).toBe(false) + const cards = (await host.journalSnapshot(SESSION)).items.filter( + (item) => item.body.kind === 'approval' + ) + expect(cards).toHaveLength(1) + expect(await cardResolution(card.itemId)).toMatchObject({ + state: 'cancelled', + resolvedBy: CALLER.callerKey + }) + expect(await statusTexts()).toEqual([]) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index fee7f9e1d3d3..8195e7c18f9b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -1,5 +1,6 @@ import { AgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' import { tryReadQueuePublication } from './structured-agent-session-queued-publication' @@ -22,6 +23,7 @@ import { export class StructuredAgentSessionClientDelivery { readonly subscribers: AgentSessionSubscribers readonly waitForSendSettlement: StructuredAgentSessionSendSettlement['wait'] + private stopAtRestCommandUpdates: () => void = () => undefined private readonly statusFeed private readonly turnCompletionFeed private readonly sendSettlement @@ -29,15 +31,18 @@ export class StructuredAgentSessionClientDelivery { constructor( private readonly sessions: Map, now: () => number, - deps: () => StructuredAgentSessionHostDeps, + private readonly deps: () => StructuredAgentSessionHostDeps, private readonly onJournalActivity?: (sessionId: string) => void, - onAgentStarted?: (sessionId: string) => void + onAgentStarted?: (sessionId: string) => void, + /** A session's child records changed; the chat strip republishes from them. */ + onChildWorkChanged?: (sessionId: string) => void ) { this.statusFeed = createStructuredAgentSessionHostStatusFeed({ sessions, now, deps, - ...(onAgentStarted ? { onAgentStarted } : {}) + ...(onAgentStarted ? { onAgentStarted } : {}), + ...(onChildWorkChanged ? { onChildWorkChanged } : {}) }) this.turnCompletionFeed = new StructuredAgentSessionTurnCompletionFeed({ sessions, @@ -49,18 +54,39 @@ export class StructuredAgentSessionClientDelivery { ) this.waitForSendSettlement = this.sendSettlement.wait this.subscribers = new AgentSessionSubscribers({ - readCommands: (sessionId) => deps().adapter.readCommands?.(sessionId), + readCommands: (sessionId) => this.readCommands(sessionId), readQueuePublication: (sessionId) => tryReadQueuePublication(sessions.get(sessionId)?.journal), onJournalPublished: (sessionId, journal) => this.publishJournal(sessionId, journal) }) } + /** Re-sends the `/` surface whenever the provider's at-rest one changes. */ + watchAtRestCommands(adapter: StructuredAgentSessionHostDeps['adapter']): void { + this.stopAtRestCommandUpdates = + adapter.atRestCommands?.onChange(() => this.subscribers.republishCommands()) ?? + (() => undefined) + } + + /** What the running agent reports; with none running, what the provider would read at rest. */ + readCommands(sessionId: string) { + const { adapter, store } = this.deps() + const live = adapter.readCommands?.(sessionId) + if (live !== undefined) { + return live + } + const record = store.getRecord(sessionId) + return record ? adapter.atRestCommands?.read(record) : undefined + } + publishStatus = (sessionId: string): void => this.statusFeed.publish(sessionId) publishChildWork = (sessionId: string, evidence: AgentChildWorkEvidence[]): void => this.statusFeed.publishChildWork(sessionId, evidence) + readChildWork = (sessionId: string): AgentChildWorkView[] | undefined => + this.statusFeed.readChildWork(sessionId) + publishStatusAndSettlement = (sessionId: string): void => { this.statusFeed.publish(sessionId) const journal = this.sessions.get(sessionId)?.journal @@ -94,6 +120,7 @@ export class StructuredAgentSessionClientDelivery { } closeAll(): void { + this.stopAtRestCommandUpdates() this.sendSettlement.closeAll() } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts new file mode 100644 index 000000000000..83d80437de3c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts @@ -0,0 +1,340 @@ +// A turn the host cuts short by closing its provider is the user's cancellation only when the user +// closed this chat. A quit, an idle eviction or a teardown aimed elsewhere leaves it news: the user +// needs to learn it did not finish. The adapter settles its own open turn with the cause the host +// hands its close, and the host's fallback settles any turn no adapter did, through one mapping. + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalTurnLifecycle +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { describeNativeChatTurnStatus } from '../../../shared/native-chat-turn-status' +import { selectStructuredAgentSettledTurns } from '../../../shared/structured-agent-session-turn-timing' +import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' +import { + adapter, + attach, + hostTestState, + replaceHostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD +} from './structured-agent-session-host-test-data' +import { + childEndCauseOfEndedEvent, + turnVerdictForChildEnd +} from './structured-agent-session-stale-turn-verdict' + +const CUT_TURN = { provider: 'codex' as const, threadId: THREAD, turnId: 'cut-turn', ordinal: 1 } + +let host: StructuredAgentSessionHost +/** What the provider writes on the open turn as it exits: settled through the mapping with the + * cause its close was handed, as both adapters do, or its own verdict; null writes nothing. */ +let providerEnd: + | 'mapped' + | Pick + | null +/** The provider already saw its own exit when the close arrived. */ +let exitObservedFirst: boolean +let closeCalls = 0 + +beforeEach(() => { + const state = hostTestState() + providerEnd = 'mapped' + exitObservedFirst = false + closeCalls = 0 + host = new StructuredAgentSessionHost({ + store: state.store, + adapter: { + ...adapter(), + closeSession: async (_sessionId, cause) => { + closeCalls += 1 + const events = state.acquire.mock.calls.at(-1)?.[0].events + if (providerEnd === null) { + return true + } + const end = + providerEnd === 'mapped' + ? turnVerdictForChildEnd( + childEndCauseOfEndedEvent({ + type: 'ended', + cause: exitObservedFirst ? 'unexpected-exit' : 'requested-close', + ...(cause ? { stopCause: cause } : {}) + }), + 1_500 + ) + : providerEnd + events?.appendItem( + CUT_TURN, + { + kind: 'turn', + turnId: 'cut-turn', + startedAt: 1_000, + requestedAt: 1_000, + ...end + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + return true + } + }, + journalDatabase: openTestJournalHostDatabase(state.root), + recoveryCapsule: new AgentSessionRecoveryCapsule(state.root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => HOST_TEST_NOW + }) + replaceHostTestState({ store: state.store, host }) +}) + +/** A running turn, anchored to its user row, with a status list watching the session. */ +async function runningTurn(): Promise { + await attach() + const events = hostTestState().acquire.mock.calls[0]?.[0].events + if (!events) { + throw new Error('missing provider event sink') + } + events.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'cut-turn', ordinal: 0 }, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'long job' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + events.appendItem( + CUT_TURN, + { + kind: 'turn', + turnId: 'cut-turn', + state: 'running', + startedAt: 1_000, + requestedAt: 1_000 + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await host.flushStreamedEvents(SESSION) + const statuses: AgentSessionStatusEvent[] = [] + host.subscribeStatus({ id: 'list', emit: (event) => statuses.push(event) }) + return statuses +} + +/** What the settle wrote, read back from the journal the next reader opens. */ +async function settledTurn() { + await host.restoreReadableSessions([SESSION]) + const { items } = await host.journalSnapshot(SESSION) + const turn = items.map((item) => readAgentJournalTurn(item.body)).find(Boolean) + const [settled] = [...selectStructuredAgentSettledTurns(items).values()] + return { turn, settled } +} + +function lastSummary(statuses: AgentSessionStatusEvent[]) { + const last = statuses.at(-1) + return last?.type === 'status' ? last.session : null +} + +describe('a turn cut short by closing its provider', () => { + it("records the user's close of this chat as their cancellation", async () => { + const statuses = await runningTurn() + + await host.close(SESSION, 'user-close') + + expect(lastSummary(statuses)).toMatchObject({ status: 'idle', turnOutcome: 'cancellation' }) + const { turn, settled } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect( + settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) + ).toMatchObject({ key: 'interruptedAfter' }) + }) + + it('leaves a close the user did not aim at this chat as news', async () => { + const statuses = await runningTurn() + + // What an idle eviction, a worktree teardown or an orchestration stop issues. + await host.close(SESSION, 'evict') + + expect(lastSummary(statuses)).toMatchObject({ status: 'idle', turnOutcome: 'interruption' }) + const { turn, settled } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted' }) + expect(turn).not.toHaveProperty('outcome') + expect( + settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) + ).toMatchObject({ key: 'failedAfter' }) + }) + + it("records the user's close on a turn no adapter settled, through the host's fallback", async () => { + providerEnd = null + const statuses = await runningTurn() + + await host.close(SESSION, 'user-close') + + expect(lastSummary(statuses)).toMatchObject({ status: 'idle', turnOutcome: 'cancellation' }) + const { turn } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + }) + + it("records the user's close on a turn whose start landed only as the provider stopped", async () => { + // A send echo still in flight when the close arrives: the journal has no turn yet. + await attach() + await host.flushStreamedEvents(SESSION) + + await host.close(SESSION, 'user-close') + + const { turn } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + }) + + it('leaves a turn cut off before the close as news', async () => { + providerEnd = null + await attach() + const events = hostTestState().acquire.mock.calls[0]?.[0].events + // An earlier death the user already saw as Interrupted, then closed. + events?.appendItem( + CUT_TURN, + { + kind: 'turn', + turnId: 'cut-turn', + state: 'interrupted', + startedAt: 1_000, + requestedAt: 1_000, + completedAt: 1_200 + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await host.flushStreamedEvents(SESSION) + + await host.close(SESSION, 'user-close') + + const { turn } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted', completedAt: 1_200 }) + expect(turn).not.toHaveProperty('outcome') + }) + + it('keeps a turn the provider finished during the stop as finished', async () => { + providerEnd = { state: 'completed', outcome: 'success', completedAt: 1_500 } + await runningTurn() + + await host.close(SESSION, 'user-close') + + const { turn } = await settledTurn() + expect(turn).toMatchObject({ state: 'completed', outcome: 'success' }) + }) + + it('keeps a verdict the provider gave on its way out', async () => { + // How Codex records a turn it reports failed. + providerEnd = { state: 'interrupted', outcome: 'failure', completedAt: 1_500 } + await runningTurn() + + await host.close(SESSION, 'user-close') + + const { turn } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted', outcome: 'failure' }) + }) + + it('leaves a quit as news', async () => { + const statuses = await runningTurn() + + await host.flushAllStreamedEvents({ trigger: 'quit' }) + + expect(statuses.findLast((event) => event.type === 'status')).toMatchObject({ + session: { status: 'idle', turnOutcome: 'interruption' } + }) + const { settled } = await settledTurn() + expect( + settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) + ).toMatchObject({ key: 'failedAfter' }) + }) + + it("keeps the user's cancellation when a close aborts after the provider settled, then retries", async () => { + await runningTurn() + const sink = host['runtimeState'].eventSinkFor(SESSION) + const drained = sink.drained.bind(sink) + let failed = false + vi.spyOn(sink, 'drained').mockImplementation(async () => { + if (closeCalls > 0 && !failed) { + failed = true + return { ok: false, error: new Error('drain failed') } + } + return drained() + }) + // The adapter settled the turn, then the close aborted at the drain after it. + await expect(host.close(SESSION, 'user-close')).rejects.toThrow() + expect(closeCalls).toBe(1) + await host.close(SESSION, 'user-close') + const { turn } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + }) + + it.each([ + ['user-close', { outcome: 'cancellation' }], + ['evict', { outcome: undefined }] + ] as const)( + "keeps a %s's cause when the idle sweep finishes a wind-down it could not", + async (cause, verdict) => { + providerEnd = null + await runningTurn() + const sink = host['runtimeState'].eventSinkFor(SESSION) + const drained = sink.drained.bind(sink) + let failed = false + vi.spyOn(sink, 'drained').mockImplementation(async () => { + if (closeCalls > 0 && !failed) { + failed = true + return { ok: false, error: new Error('drain failed') } + } + return drained() + }) + // The provider is proven gone, then the close aborts before the host settles its turn. + await expect(host.close(SESSION, cause)).rejects.toThrow() + + await host.collaboratorsForTests().lifetime.idleSweep.tick() + + expect(closeCalls).toBe(1) + const { turn } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted' }) + expect(turn?.outcome).toBe(verdict.outcome) + } + ) + + it("leaves a quit's cut on a turn no adapter settled as news", async () => { + providerEnd = null + await runningTurn() + + await host.flushAllStreamedEvents({ trigger: 'quit' }) + + const { turn } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted' }) + expect(turn).not.toHaveProperty('outcome') + }) + + it('leaves a crash the provider saw before the user closed the chat as news', async () => { + exitObservedFirst = true + await runningTurn() + // The provider reports its own exit, which it saw first, as it closes: no verdict. + await host.close(SESSION, 'user-close') + const { turn } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted' }) + expect(turn).not.toHaveProperty('outcome') + }) + + it.each(['user-close', 'evict'] as const)( + 'closes the conversation of a chat a %s ends, as any close does', + async (cause) => { + await runningTurn() + + await host.close(SESSION, cause) + + expect(host.hasSession(SESSION)).toBe(false) + } + ) +}) + +it('requires every stop to name its cause', () => { + type StopArgs = Parameters + // @ts-expect-error a stop that names no cause must not compile, or it would default to one + const omitted: StopArgs = [host['lifetimeContext'](), SESSION] + expect(omitted).toHaveLength(2) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts index 993ee3fd2581..ef007e03d6be 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts @@ -12,6 +12,7 @@ import { projectStructuredAgentSessionStatusSummary } from '../../../shared/stru import { projectTurnItemHistory } from '../../runtime/rpc/methods/structured-agent-session-turn-item-capability' import { structuredAgentSessionWorkingAtStop } from './structured-agent-session-working-at-teardown' import { + childRecord, journal, NOW, record, @@ -131,7 +132,7 @@ describe('the completion feed around /compact (B6)', () => { expect(latestStructuredAgentSessionRequest(items, submissions)).toMatchObject({ kind: 'turn', id: 'turn-1', - running: false, + turnState: 'completed', outcome: 'success' }) } @@ -144,8 +145,8 @@ describe('restart resume around /compact (B14)', () => { sessionId: SESSION, session: { journal: journal(items), child: { fence: 1 } }, getRecord: () => record(), - backgroundTasks: () => - tasks ? [{ id: 'task-a', kind: 'agent', description: 'Review', state: 'working' }] : [], + childWork: () => + tasks ? [childRecord({ id: 'task-a', kind: 'agent', description: 'Review' })] : [], trigger: 'quit', teardownId: TEARDOWN_CURRENT, now: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts index cb83dba870a1..25b6addb3c58 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts @@ -29,6 +29,7 @@ import { } from '../../../shared/agent-session-journal-types' import type { AgentSessionConversationCommand } from '../../../shared/agent-session-conversation-command' import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' import { agentJournalTurnBody, @@ -153,6 +154,8 @@ export type StructuredAgentSessionCommandHandoverContext = { /** Who a failure the handover meets names, as the start's own row does. */ failureTextContext?: AgentSessionFailureWordsContext record: () => AgentSessionRecord | null + /** The session's child records, the same read the strip and conversation-command admission use. */ + childWork: () => readonly AgentChildWorkView[] | undefined flushStreamedEvents: () => Promise now: () => number } @@ -313,7 +316,7 @@ function commandBlocked( if (!record) { return agentSessionFailureFact('hostFault') } - const refusal = conversationCommandBlocked(ctx, record, 'handover') + const refusal = conversationCommandBlocked(ctx, record, ctx.childWork(), 'handover') return refusal ? agentSessionFailureFact('commandRefused', { refusal: agentSessionRefusalReference(refusal) }) : null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index bf3cd1f96a60..2dd2b5229aa7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -137,8 +137,8 @@ describe('closing the handle', () => { it('keeps the row when a chat with an open tab is evicted, and forgets it once the tab closes', async () => { await foundRestTestChat(rig) - await rig.host.close(SESSION) - expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) + await rig.host.close(SESSION, 'evict') + expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict') // The stop says not-running; the row belongs to the tab, so nothing forgets it. expect(rig.sink.forget).not.toHaveBeenCalled() expect(rig.sink.publish.mock.calls.at(-1)?.[0]).toMatchObject({ sessionId: SESSION }) @@ -261,7 +261,7 @@ describe('a start that never finishes (P2-15)', () => { rig.clock.now += IDLE_MS + 1 await sweepOnce(rig.host) - expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) + expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'host-stop') await vi.waitFor(() => expect(readerSaw(reader.events).submissions).toContainEqual( expect.objectContaining({ dispatchState: 'rejected', reason: stopReason }) @@ -311,8 +311,9 @@ describe('the wind-down retry with a message queued (P2-31)', () => { now: () => IDLE_MS + 1, isDisposed: () => false, deliveryActive: () => true, - backgroundTaskState: () => undefined, + childWork: () => undefined, hasOpenDispatch: () => false, + providerHoldsDispatch: () => false, stopAgent, stopStartingAgent: stopAgent, closeConversation: vi.fn(async () => false), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index f69129183b4a..1980616f1765 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -9,14 +9,17 @@ import { AgentSessionRefusalError, agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { createJournalOpenReadRefusals } from '../agent-session-journal/journal-open-failure' import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations' import { abandonQueuedStructuredAgentSessionMessages, closeStructuredAgentSessionConversationUnderSerialize, stopStructuredAgentSessionAgentUnderSerialize, + type StructuredAgentSessionCloseCause, type StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' +import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' @@ -36,6 +39,8 @@ export function createStructuredAgentSessionConversationLifetime(host: { deliveryActive: (sessionId: string) => boolean /** The handle closed: `listed` keeps the chat's row in the agent-status store for its tab. */ closeStatus: (sessionId: string, options: { listed: boolean }) => void + /** The session's child records, the host's one read of them. */ + readChildWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined }) { let disposed = false const { sessions, serialize } = host @@ -46,9 +51,8 @@ export function createStructuredAgentSessionConversationLifetime(host: { session.journal.whenImported().catch((error: unknown) => { throw readRefusals.refusal(sessionId, error) }) - // The sweep's stop puts an idle agent to rest: nothing is queued, so no loop reads its cause. - const stopAgent = (sessionId: string) => - stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId) + const stopAgent = (sessionId: string, cause: StructuredAgentSessionStopCause) => + stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause }) const closeConversation = (sessionId: string): Promise => closeStructuredAgentSessionConversationUnderSerialize( @@ -69,12 +73,14 @@ export function createStructuredAgentSessionConversationLifetime(host: { now: () => host.context().now(), isDisposed: () => disposed, deliveryActive: host.deliveryActive, - backgroundTaskState: (sessionId) => deps().adapter.backgroundTaskState?.(sessionId), + childWork: host.readChildWork, hasOpenDispatch: (sessionId) => { const record = deps().store.getRecord(sessionId) return record !== null && deps().hasOpenDispatch?.(record) === true }, - stopAgent, + providerHoldsDispatch: (sessionId) => deps().adapter.holdsDispatch?.(sessionId) === true, + // The host puts an idle agent to rest: a turn it cuts short is news, not the user's Stop. + stopAgent: (sessionId) => stopAgent(sessionId, 'evict'), // A host stop: the delivery loop waiting on this child writes the one error row and rejects // what is queued with it, both worded from the hostStopped fact. stopStartingAgent: (sessionId) => @@ -145,7 +151,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { }, /** Ends a chat's resources, not the chat: its record and journal stay on disk, and what is * still queued will not be sent. */ - close: (sessionId: string): Promise => + close: (sessionId: string, cause: StructuredAgentSessionCloseCause): Promise => serialize(sessionId, async () => { readRefusals.forget(sessionId) const session = sessions.get(sessionId) @@ -153,9 +159,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { // Abandoned before the stop, so no start delivers it. await abandonQueuedStructuredAgentSessionMessages(deps(), sessionId, session.journal) } - await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { - cause: 'evict' - }) + await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause }) await closeConversation(sessionId) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index 2eeae4787ce5..488dcdfd2d01 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -36,6 +36,9 @@ let host: StructuredAgentSessionHost let dispatch: Mock let cancelTurn: Mock let awaitStarted: Mock> +let closeSession: Mock> +/** Codex's answer by default: its Stop keeps the child. */ +let stopEndsSession: boolean let events: StructuredAgentSessionEventSink | undefined function eventually(assertion: () => void | Promise): Promise { @@ -49,6 +52,8 @@ beforeEach(async () => { dispatch = vi.fn(async () => ({ state: 'admitted' as const })) cancelTurn = vi.fn(async () => ({ cancelled: true })) awaitStarted = vi.fn(async () => undefined) + closeSession = vi.fn(async () => true) + stopEndsSession = false store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, @@ -74,9 +79,10 @@ beforeEach(async () => { }, dispatch, awaitStarted, - closeSession: vi.fn(async () => true), + closeSession, releaseAcquisition: vi.fn(async () => true), cancelTurn, + stopEndsSession: () => stopEndsSession, answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) }, @@ -365,3 +371,78 @@ describe('a Stop that names its turn, as an older client sends it', () => { expect(await statusRows()).toEqual([]) }) }) + +describe('a Stop on a provider whose Stop ends its session', () => { + async function handedOver(): Promise { + const { id, result } = send('hello') + await result + await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) + } + + /** The Stop's second step, which ends the child, runs next on the session's lane. */ + function laneDrained(): Promise { + return host['tasks'].serialize(SESSION, async () => {}) + } + + it('ends the child after the cancel even when the provider refused it, and says only that it was asked', async () => { + stopEndsSession = true + await handedOver() + cancelTurn.mockResolvedValueOnce({ + cancelled: false, + refusal: { detail: { text: 'no active turn to interrupt', audience: 'person' } } + }) + + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() + + expect(closeSession).toHaveBeenCalledWith(SESSION, 'user-stop') + expect(await statusRows()).toEqual(['Cancellation requested.']) + }) + + it('keeps the child of a provider whose Stop is not a session boundary', async () => { + await handedOver() + cancelTurn.mockResolvedValueOnce({ cancelled: true }) + + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() + + expect(closeSession).not.toHaveBeenCalled() + }) + + it('leaves the child alone when the Stop named a turn that is no longer live', async () => { + stopEndsSession = true + cancelTurn.mockResolvedValueOnce({ cancelled: false }) + + expect(await stop('turn-1')).toMatchObject({ ok: true, value: { cancelled: false } }) + await laneDrained() + + expect(closeSession).not.toHaveBeenCalled() + expect(await statusRows()).toEqual([ALREADY_FINISHED]) + }) + + it("ends the child when the provider's cancel of a Stop naming a turn no longer live fails", async () => { + stopEndsSession = true + await handedOver() + // The interrupt went out and its answer was lost: what it stopped is unknown. + cancelTurn.mockRejectedValueOnce(new Error('control request lost')) + + expect(await stop('turn-1')).toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() + + expect(closeSession).toHaveBeenCalledWith(SESSION, 'user-stop') + expect(await statusRows()).toEqual(['Cancellation requested.']) + }) + + it('ends the child when the provider took a Stop naming a turn that is no longer live', async () => { + stopEndsSession = true + await handedOver() + // The interrupt stopped the follow-up in flight, which has no turn a client could name. + cancelTurn.mockResolvedValueOnce({ cancelled: true }) + + expect(await stop('turn-1')).toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() + + expect(closeSession).toHaveBeenCalledWith(SESSION, 'user-stop') + expect(await statusRows()).toEqual(['Cancellation requested.']) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts index c312d6013fd5..70fe538ce202 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts @@ -17,6 +17,7 @@ export class StructuredAgentSessionConversations extends Map< StructuredAgentSessionHostSession > { private readonly activity = new Map() + private readonly closeObservers = new Set<(sessionId: string) => void>() constructor( private readonly delivery: { @@ -58,7 +59,18 @@ export class StructuredAgentSessionConversations extends Map< override delete(sessionId: string): boolean { this.activity.delete(sessionId) - return super.delete(sessionId) + const deleted = super.delete(sessionId) + if (deleted) { + for (const observer of this.closeObservers) { + observer(sessionId) + } + } + return deleted + } + + /** Told when a conversation leaves the map, so state kept per conversation dies with it. */ + observeClose(observer: (sessionId: string) => void): void { + this.closeObservers.add(observer) } touch(sessionId: string): void { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index c7f8f09631a2..9d396b58ffa7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -10,10 +10,14 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import type { + AgentSessionStatusSummary, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { describeNativeChatTurnStatus } from '../../../shared/native-chat-turn-status' import { completedStructuredAgentTurnSeconds, selectStructuredAgentTurnTimings @@ -291,6 +295,36 @@ describe('a turn a read reached before the reconcile proved its owner dead', () unsubscribe() }) + it('reports the revision to the status feed as an interruption, which the chat folds as failed', async () => { + const published: AgentSessionStatusSummary[] = [] + openHost({ + probeOwner: async () => ({ outcome: 'pid-absent' }), + statusSink: { publish: (summary) => published.push(summary), forget: () => {} } + }) + await host.history({ sessionId: SESSION, direction: 'tail' }) + const outcomes = () => + published + .filter((summary) => summary.sessionId === SESSION && summary.turnOutcome) + .map((summary) => summary.turnOutcome) + expect(outcomes().at(-1)).toBe('unconfirmed') + + await host.reconcileRestartLeases() + await drainSession() + + // The sidebar's red Failed, then the folded "Failed after 27s". + await vi.waitFor(() => expect(outcomes().at(-1)).toBe('interruption')) + const [timing] = selectStructuredAgentTurnTimings( + (await host.journalSnapshot(SESSION)).items + ).values() + expect( + describeNativeChatTurnStatus({ + elapsedSeconds: 0, + workedSeconds: completedStructuredAgentTurnSeconds(timing), + verdict: timing?.verdict + }) + ).toEqual({ key: 'failedAfter', duration: '27s' }) + }) + it('revises nothing twice, whoever re-runs the settle', async () => { openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) }) await host.history({ sessionId: SESSION, direction: 'tail' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 373e2ae3c36e..ad149dbf9472 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -10,7 +10,9 @@ // would have written them. Stop and the conversation's close are the only other writers of a // queued message: a child's exit only ends the child, and this loop reads why. +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentSessionFailureFact, type SubmissionRejectionFact @@ -53,10 +55,16 @@ export type StructuredAgentSessionDeliveryLoopDeps = { ) => Promise /** The fence the conversation's own writes carry; see `structuredAgentSessionConversationFence`. */ conversationFence: (sessionId: string) => number + /** Rejects queued messages as a completed close of the chat does; false when that failed. */ + abandonQueued: ( + sessionId: string, + which: (submission: AgentJournalSubmission) => boolean + ) => Promise /** Who the chat's failure sentences name. */ failureTextContext: (sessionId: string) => AgentSessionFailureWordsContext onError: (sessionId: string, error: unknown) => void record: (sessionId: string) => AgentSessionRecord | null + readChildWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined flushStreamedEvents: (sessionId: string) => Promise now: () => number } @@ -107,7 +115,13 @@ export class StructuredAgentSessionDeliveryLoop { } if (!prepared.ok) { const { refusal, diagnostic } = prepared - const cause = { refusal, ...(diagnostic ? { diagnostic } : {}) } + // A conversation no agent ever ran, such as a cleared chat's, failed to start, not restart. + const newSession = this.deps.record(sessionId)?.providerHandleChain.length === 0 + const cause = { + refusal, + ...(diagnostic ? { diagnostic } : {}), + ...(newSession ? { newSession: true as const } : {}) + } await this.deps.serialize(sessionId, () => this.fail(sessionId, { startKey: null, cause }) ) @@ -149,6 +163,10 @@ export class StructuredAgentSessionDeliveryLoop { // A handle closes only with nothing queued, so one an earlier handle wrote is a leftover. (submission) => session.journal.wroteBeforeOpen(submission.acceptedSequence) ) + if (!(await this.closeWhatTheUserClosed(sessionId, session))) { + // Never start an agent for a message the user closed; the next wake re-derives and retries. + return this.stop(sessionId) + } const oldest = oldestQueuedSubmission(session) // A running command takes no input while its child carries it; its end is a commit, which // wakes the loop again. With no child it is a gone generation's, which the start below settles. @@ -193,8 +211,8 @@ export class StructuredAgentSessionDeliveryLoop { // The child waited on is gone, replaced by another, or settled its start without proving it. const ended = awaitedChild ? undefined : session.lastEndedChild const endedFailure = ended ? structuredAgentSessionEndedChildFailure(ended) : undefined - // A user's Stop is not a failure: the next step starts, or waits on, a child for what is - // queued. + // A user's Stop or close is not a failure: the next step starts, or waits on, a child for + // what is queued, after closing what a close of the chat closed. if (endedFailure === null) { return 'continue' } @@ -218,6 +236,7 @@ export class StructuredAgentSessionDeliveryLoop { providerChildPhase: () => this.deps.sessions.get(sessionId)?.child?.phase, failureTextContext: this.deps.failureTextContext(sessionId), record: () => this.deps.record(sessionId), + childWork: () => this.deps.readChildWork(sessionId), flushStreamedEvents: () => this.deps.flushStreamedEvents(sessionId), now: this.deps.now }, @@ -243,6 +262,27 @@ export class StructuredAgentSessionDeliveryLoop { return this.stop(sessionId) } + /** A close of this chat that stopped its child and then did not complete still closed what was + * queued before it, so no child starts for those. Ordered, not latched: a later send goes on. + * False when those could not be closed. */ + private async closeWhatTheUserClosed( + sessionId: string, + session: StructuredAgentSessionHostSession + ): Promise { + const ended = session.lastEndedChild + if (session.child || ended?.cause !== 'user-close') { + return true + } + const { epoch } = session.journal.cursor() + return this.deps.abandonQueued( + sessionId, + (submission) => + ended.endedAt.epoch === epoch && + submission.acceptedSequence !== undefined && + submission.acceptedSequence <= ended.endedAt.sequence + ) + } + /** Inside the serialized step that found nothing to do, so an accept after it wakes anew. */ private stop(sessionId: string): 'stop' { this.running.delete(sessionId) @@ -281,6 +321,8 @@ function providerEndFailure( // Every end cause, so a new one does not compile until it says whether it fails what is queued. const ENDED_CHILD_FAILURE = { 'user-stop': () => null, + // The user closing this chat closes what was queued before it; see `closeWhatTheUserClosed`. + 'user-close': () => null, // The host stopping the child is Orca's cause, never the provider's: a start that never finished. 'host-stop': () => ({ failure: agentSessionFailureFact('hostStopped') }), exit: providerEndFailure, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index f7a50627370e..56a0acf853d4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts @@ -1,8 +1,6 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { - stopAgentSessionProviderRoot, - type StructuredAgentSessionLifecycleEvent -} from './structured-agent-session-adapter' +import type { StructuredAgentSessionLifecycleEvent } from './structured-agent-session-adapter' +import { stopAgentSessionProviderRoot } from './structured-agent-session-provider-exit-proof' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts index 8fc810de2a4d..38349f95115e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts @@ -76,7 +76,7 @@ describe('structured agent session eviction', () => { await evictStructuredAgentSession(ctx) - expect(disposeSession).toHaveBeenCalledWith('session-1') + expect(disposeSession).toHaveBeenCalledWith('session-1', undefined) expect(closeSession).not.toHaveBeenCalled() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts index 226781117bdf..182d2e701921 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts @@ -17,16 +17,19 @@ // reach it; forgetting it anyway stranded the process forever and reported success. Leaving the // session in place is what makes the next close a real retry instead of a no-op. -import { - stopAgentSessionProviderRoot, - type StructuredAgentSessionAdapter +import type { + StructuredAgentSessionAdapter, + StructuredAgentSessionStopCause } from './structured-agent-session-adapter' +import { stopAgentSessionProviderRoot } from './structured-agent-session-provider-exit-proof' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { StructuredAgentSessionStopVerdict } from './structured-agent-session-host-types' import { withTimeout } from '../../../shared/promise-timeout-fallback' export type StructuredAgentSessionEvictionContext = { sessionId: string + /** Why the host stops the child; the adapter settles the turn it cuts with it. */ + stopCause?: StructuredAgentSessionStopCause hasProviderChild?: boolean eventSink: DeferredStructuredAgentSessionEventSink adapter: StructuredAgentSessionAdapter @@ -86,7 +89,9 @@ export const STRUCTURED_AGENT_SESSION_EVICTION_STEPS: readonly StructuredAgentSe // An adapter with no close has nothing to stop; anything else must PROVE the exit. const stop = context.adapter.disposeSession ?? context.adapter.closeSession const rootGone = stop - ? await stopAgentSessionProviderRoot(() => stop.call(context.adapter, context.sessionId)) + ? await stopAgentSessionProviderRoot(() => + stop.call(context.adapter, context.sessionId, context.stopCause) + ) : true if (!rootGone) { throw new Error('provider child exit was not proven') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index ed2e48fd562b..5d8a4d0429ba 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -3,6 +3,7 @@ // with a message queued has a delivery loop — and the open is where a loop for leftovers wakes. import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { abandonQueuedStructuredAgentSessionMessages } from './structured-agent-session-host-lifetime' import type { AgentJournalResetReason } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { @@ -11,6 +12,7 @@ import { type OpenedStructuredAgentSessionConversation, type StructuredAgentSessionConversationOpenOptions } from './structured-agent-session-conversation-open' +import type { StructuredAgentSessionClientDelivery } from './structured-agent-session-client-delivery' import { StructuredAgentSessionDeliveryLoop } from './structured-agent-session-delivery-loop' import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' @@ -53,7 +55,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { startedFor: string ) => Promise reset: (sessionId: string, journal: AgentSessionJournal, reset: AgentJournalResetReason) => void - publishRestored: (sessionId: string) => void + clientDelivery: Pick flushStreamedEvents: (sessionId: string) => Promise }): StructuredAgentSessionConversationDelivery { const { deps, sessions } = input @@ -65,6 +67,12 @@ export function createStructuredAgentSessionConversationDelivery(input: { ensureProviderChild: input.ensureProviderChild, conversationFence: (sessionId) => structuredAgentSessionConversationFence(deps.store, sessionId), + abandonQueued: async (sessionId, which) => { + const session = sessions.get(sessionId) + return session + ? abandonQueuedStructuredAgentSessionMessages(deps, sessionId, session.journal, which) + : true + }, failureTextContext: (sessionId) => structuredAgentSessionFailureWordsContext( deps.store.getRecord(sessionId), @@ -72,6 +80,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { ), onError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }), record: (sessionId) => deps.store.getRecord(sessionId), + readChildWork: input.clientDelivery.readChildWork, flushStreamedEvents: input.flushStreamedEvents, now: () => deps.now?.() ?? Date.now() }) @@ -84,7 +93,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { if (reset) { input.reset(sessionId, session.journal, reset) } - input.publishRestored(sessionId) + input.clientDelivery.publishRestored(sessionId) await settleInterruptedCommands(deps, sessionId, session) if (session.journal.submissions().some(isQueuedAgentJournalSubmission)) { loop.wake(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index f2140c98fbc7..719540da5da8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -9,6 +9,7 @@ // reentrant, so every public entry point takes it once and calls these. import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { @@ -19,7 +20,6 @@ import { import { withStructuredAgentSessionEvictionDeadline } from './structured-agent-session-eviction-deadline' import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import type { - StructuredAgentSessionChildEndCause, StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession, StructuredAgentSessionProviderChildIdentity @@ -30,6 +30,8 @@ import { } from './structured-agent-session-provider-child' import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release' import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' +import { turnVerdictForChildEnd } from './structured-agent-session-stale-turn-verdict' +import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' export type StructuredAgentSessionLifetimeContext = { deps: StructuredAgentSessionHostDeps @@ -51,18 +53,27 @@ type ConversationCloseDeps = Pick { - await journal + journal: StructuredAgentSessionHostSession['journal'], + which?: (submission: AgentJournalSubmission) => boolean +): Promise { + return journal .rejectQueuedSubmissions( structuredAgentSessionConversationFence(deps.store, sessionId), - agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) + agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }), + which + ) + .then( + () => true, + (error: unknown) => { + deps.onEventSinkError?.({ sessionId, error }) + return false + } ) - .catch((error: unknown) => deps.onEventSinkError?.({ sessionId, error })) } /** The wind-down this host owes for the session's child. A live child always owes one, whatever a @@ -85,20 +96,22 @@ function owedProviderChildWindDown( export async function stopStructuredAgentSessionAgentUnderSerialize( context: StructuredAgentSessionLifetimeContext, sessionId: string, - ending: { - cause: Extract - reason?: string - } = { cause: 'user-stop' } + // Required: an omitted cause must not default to the user's cancellation. + ending: { cause: StructuredAgentSessionStopCause; reason?: string } ): Promise { const session = context.sessions.get(sessionId) if (!session) { return } + // A retry finishes the stop that ended the child, so the turn that stop cut keeps its cause. + const cause = session.child + ? ending.cause + : (session.owesProviderChildWindDown?.cause ?? ending.cause) // The obligation OUTLIVES the child. `child` is ended the instant the adapter proves the exit, // so a step that aborts after that point would otherwise leave the retry reading "no child // here" and skipping the settlement and the lease release it still owes. const owed = owedProviderChildWindDown(session) - session.owesProviderChildWindDown = owed + session.owesProviderChildWindDown = owed ? { ...owed, cause } : undefined const stopping = session.child let settlementError: unknown const eviction: StructuredAgentSessionEvictionContext = { @@ -108,6 +121,8 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( owesProviderChildWindDown: owed !== undefined, eventSink: context.runtimeState.eventSinkFor(sessionId), adapter: context.deps.adapter, + // The adapter settles its own open turn with this, so who asked travels with the stop. + stopCause: cause, ...(context.restartWitness ? { beforeProviderChildStop: () => context.restartWitness?.beforeStop(sessionId) } : {}), @@ -117,7 +132,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( endProviderChild(session, { generation: stopping.generation, fence: stopping.fence, - cause: ending.cause, + cause, reason: ending.reason ?? null, duringStartup: stopping.phase === 'starting', ...verdict @@ -136,7 +151,8 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( fence, settlementId: `expected-close:${sessionId}:${fence}:${owed?.generation ?? 'unknown'}`, pendingSubmissionReason: 'provider_closed_before_acknowledgement', - verdict: { state: 'interrupted', completedAt: context.now() }, + // Only a turn no adapter settled: one with no close, or whose settle threw. + verdict: turnVerdictForChildEnd(cause, context.now()), showUnexpectedExitOutcome: false, onError: (id, error) => { settlementError = error @@ -170,6 +186,12 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( ) } +/** A close's cause: the user closing this chat, or the host evicting it (quit, idle, teardown). */ +export type StructuredAgentSessionCloseCause = Extract< + StructuredAgentSessionStopCause, + 'user-close' | 'evict' +> + /** Whether the conversation's handle is only a cache now: no child, no wind-down owed, and nothing * queued or waiting on the provider. */ export function structuredAgentSessionConversationClosable( @@ -228,7 +250,7 @@ export async function evictOwnedStructuredAgentSessions( ownedSessionIds.map(async (sessionId) => { try { await context.serialize(sessionId, () => - stopStructuredAgentSessionAgentUnderSerialize(context, sessionId) + stopStructuredAgentSessionAgentUnderSerialize(context, sessionId, { cause: 'evict' }) ) retainOnFailure.delete(sessionId) } catch (error) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 01143eb4bb47..5067bcb3fb2d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -7,10 +7,7 @@ // admitted without the writer lease; the delivery loop starts the provider child a send needs, and // an operation only the provider can perform starts it before admission. -import type { - AgentJournalItemIdentity, - AgentJournalMessageItem -} from '../../../shared/agent-session-journal-types' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionCancelResult, AgentSessionMutationEnvelope, @@ -21,97 +18,34 @@ import type { AgentSessionThreadGoalChange, AgentSessionThreadGoalResult } from '../../../shared/agent-session-wire' -import { - agentSessionFailureWords, - type AgentJournalDispatchRejection -} from '../../../shared/agent-session-failure-words' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentSessionPromptRequest } from './structured-agent-session-turns-prompt' import { threadGoalPlan } from './structured-agent-session-thread-goal' import { - isMainAgentWorkingOnceFlushed, - performCancel -} from './structured-agent-session-turns-cancel' -import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import { - admitAndRunAgentSessionMutation, - type AgentSessionMutationRequest, - type AgentSessionMutationSessionPreparation -} from './structured-agent-session-mutation-admission' + mutateStructuredAgentSession, + type StructuredAgentSessionMutationContext +} from './structured-agent-session-mutation-context' import { openForWrite, openWithAgent, sendPreparation, - structuredAgentSessionFailureWordsContext, structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' import { cancelPlan, promptPlan, sendPlan, - setOptionPlan, - type MutationPlan + setOptionPlan } from './structured-agent-session-mutation-plans' import { runQueueableStructuredAgentSessionSend } from './structured-agent-session-queued-send' -import { runStopWithQueuePause } from './structured-agent-session-queued-stop' -import type { - StructuredAgentSessionCaller, - StructuredAgentSessionHostDeps, - StructuredAgentSessionHostSession -} from './structured-agent-session-host-types' +import { cancelStructuredAgentSessionPrompt } from './structured-agent-session-prompt-cancel' +import { mutateWithChatStop } from './structured-agent-session-chat-stop' +export type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' +import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import { readStructuredAgentSessionOptions, recordStructuredAgentSessionOptionIntent } from './structured-agent-session-options-read' -export type StructuredAgentSessionMutationContext = { - deps: StructuredAgentSessionHostDeps - sessions: Map - publish: (sessionId: string, journal: StructuredAgentSessionHostSession['journal']) => void - flushStreamedEvents: (sessionId: string) => Promise - /** The host's accessor, for a caller outside the session's serialize. */ - conversation: (sessionId: string) => Promise - serialize: (sessionId: string, task: () => Promise) => Promise - /** The session's conversation, opened when closed; inside the caller's serialize. */ - openConversation: (sessionId: string) => Promise - /** Gives the session a provider child; inside the caller's serialize. */ - ensureAgent: (sessionId: string) => Promise - /** A message was accepted: the session's delivery loop hands it over. */ - wakeDelivery: (sessionId: string) => void - /** Stops the session's provider child, keeping its conversation; inside the caller's serialize. */ - stopAgent: (sessionId: string) => Promise - /** Only for gate inputs living in the RECORD store, which can settle with no - * journal commit (a conversation command). Draft-table changes need no call: - * the draft store notifies through the journal's own commit listener. */ - wakeQueuedDrain?: (sessionId: string) => void - now: () => number -} - -/** Admits the envelope and runs the plan inside the session's serialize. */ -export function mutateStructuredAgentSession( - context: StructuredAgentSessionMutationContext, - caller: StructuredAgentSessionCaller, - envelope: AgentSessionMutationEnvelope, - plan: MutationPlan, - prepareSession?: AgentSessionMutationRequest['prepareSession'] -): Promise> { - return context.serialize(envelope.sessionId, () => - admitAndRunAgentSessionMutation({ - store: context.deps.store, - adapter: context.deps.adapter, - callerKey: caller.callerKey, - envelope, - plan, - journal: () => context.sessions.get(envelope.sessionId)?.journal, - prepareSession, - publish: (journal) => context.publish(envelope.sessionId, journal), - flushStreamedEvents: context.flushStreamedEvents, - providerChildPhase: () => context.sessions.get(envelope.sessionId)?.child?.phase, - now: () => context.now() - }) - ) -} - export function sendStructuredAgentSessionTurn( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, @@ -161,66 +95,28 @@ export function cancelStructuredAgentSessionTurn( prompt?: { itemId: string; expectedRevision: number } } ): Promise> { - if (params.scope || params.prompt) { + if (params.scope) { return mutateStructuredAgentSession( context, caller, params.envelope, - cancelPlan(params), + cancelPlan({ ...params, childWork: () => context.readChildWork(params.envelope.sessionId) }), openForWrite(context, params.envelope) ) } - const plan = cancelPlan({ - ...params, - stopChild: () => context.stopAgent(params.envelope.sessionId) - }) - return mutateStructuredAgentSession( - context, - caller, - params.envelope, - { - ...plan, - // Stop's queue step, the same for every client: once the Stop takes effect - // the queue is paused. The cards stay published; nothing is withdrawn and no - // text ever rides the answer. - run: (ctx) => - runStopWithQueuePause(ctx, async (tookEffect) => { - // Stop withdraws every queued SUBMISSION first, whatever the start or the child is doing. - const withdrawn = await ctx.journal.rejectQueuedSubmissions( - ctx.fence, - agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) - ) - const named = params.turnId !== undefined ? { turnId: params.turnId } : {} - const child = context.sessions.get(ctx.sessionId)?.child - if (child?.phase === 'starting') { - // A start that may never land is the one thing here Stop has to end; the chat stays. - await tookEffect() - await context.stopAgent(ctx.sessionId) - return { ok: true, value: { ...named, cancelled: true } } - } - // A Stop naming no turn ends nothing more unless the session reads working, by the rule - // every session list and the chat's own Stop read it. - const inFlight = params.turnId !== undefined || (await isMainAgentWorkingOnceFlushed(ctx)) - const record = context.deps.store.getRecord(ctx.sessionId) - if (!child || !inFlight) { - if (withdrawn.length > 0) { - await tookEffect() - } - return { ok: true, value: { ...named, cancelled: withdrawn.length > 0 } } - } - await tookEffect() - return performCancel( - { ...ctx, failureTextContext: structuredAgentSessionFailureWordsContext(record) }, - { - clientOperationId: params.envelope.clientOperationId, - ...named, - stopChild: () => context.stopAgent(params.envelope.sessionId), - withdrewQueued: withdrawn.length > 0 - } - ) - }) - }, - openForWrite(context, params.envelope) + const plan = cancelPlan(params) + const { prompt } = params + // A card's Cancel stops whatever the chat has in flight, as the Stop button does; it reaches the + // Stop only for a card the live turn raised (`cancelStructuredAgentSessionPrompt`). + const stopped = prompt ? { envelope: params.envelope } : params + return mutateWithChatStop(context, caller, stopped, plan, (ctx, stop) => + prompt + ? cancelStructuredAgentSessionPrompt( + ctx, + { ...(params.turnId !== undefined ? { turnId: params.turnId } : {}), prompt }, + { stop, interrupt: () => plan.run(ctx) } + ) + : stop().then(({ outcome }) => outcome) ) } @@ -281,41 +177,6 @@ export function changeStructuredAgentSessionThreadGoal( ) } -/** Settle provider-proven delivery independently of an in-flight client mutation. */ -export async function settleStructuredAgentSessionLateDispatch( - context: StructuredAgentSessionMutationContext, - input: { - sessionId: string - clientMessageId: string - } & ( - | { providerIdentity: AgentJournalItemIdentity } - | ({ state: 'rejected' } & AgentJournalDispatchRejection) - ) -): Promise { - const session = context.sessions.get(input.sessionId) - if (!session) { - return - } - const fence = structuredAgentSessionConversationFence(context.deps.store, input.sessionId) - // The journal queue drains before close; the host queue would defer this past teardown. - await session.journal.resolveDispatch( - 'providerIdentity' in input - ? { - clientMessageId: input.clientMessageId, - state: 'accepted', - providerIdentity: input.providerIdentity, - fence - } - : { - clientMessageId: input.clientMessageId, - state: 'rejected', - reason: input.reason, - rejection: input.rejection, - fence - } - ) -} - /** The host's thin mutation surface. Each call re-reads the context, so a session * map or fence that moves between calls is never captured by a stale closure. */ export function structuredAgentSessionMutationDelegates( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts index 4ce0b3885adf..e6700777227c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts @@ -174,7 +174,9 @@ export async function flushStructuredAgentSessionHost( retainSessionIds, acknowledgeSessionRelease: (sessionId) => context.deps.adapter.acknowledgeSessionRelease?.(sessionId), - abandonQueued: (sessionId, session) => - abandonQueuedStructuredAgentSessionMessages(context.deps, sessionId, session.journal) + // Quit's is best effort: a failure is reported, and the next open rejects the leftover. + abandonQueued: async (sessionId, session) => { + await abandonQueuedStructuredAgentSessionMessages(context.deps, sessionId, session.journal) + } }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts index 4d064cbdfaa6..2971ce4f79c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts @@ -80,7 +80,7 @@ describe('abandoning a structured agent-session host', () => { expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) // The conversation stays and its provider child does not, so the next send makes the delivery // loop start one — the shape the refusal-oracle spec ends on. - await host.close(SESSION) + await host.close(SESSION, 'evict') gate = new Promise((resolve) => { openGate = resolve }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.ts index a4069875bade..78f4ab1beb5c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.ts @@ -5,10 +5,9 @@ // // What it cannot skip is work already in flight, which is what every copy of this helper used to // get wrong. Two producers reach the session store after the last awaited call has returned — a -// lease-renewal tick, and the restart the delivery loop wakes for an accepted send — and the store -// re-creates its own directory before every commit. A commit landing after the test removed its -// temp directory therefore puts that directory back, and the removal fails with ENOTEMPTY. Quit -// waits for both, in its `stop-lease-renewal` and `drain-attaches` phases; so does this. +// lease-renewal tick, and the restart the delivery loop wakes for an accepted send — and a commit +// landing after the test's cleanup meets a journal database already closed. Quit waits for both, +// in its `stop-lease-renewal` and `drain-attaches` phases; so does this. import type { StructuredAgentSessionHost } from './structured-agent-session-host' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts index 1a701f5b13cc..f29d1b68e69c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts @@ -1,5 +1,4 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -8,6 +7,7 @@ import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-ses import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { @@ -20,6 +20,7 @@ import type { } from './structured-agent-session-adapter' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { TrackedTestRecoveryCapsule } from './structured-agent-session-host-test-recovery-capsule' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -58,6 +59,7 @@ const attachParams = ( const ensureParams = (fence: number): AgentSessionAttachParams => hostTestAttachParams(fence) let root: string +let recoveryCapsule: TrackedTestRecoveryCapsule let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let acquire: Mock @@ -149,11 +151,12 @@ beforeEach(async () => { answerPrompt = vi.fn(async ({ commit }) => commit()) setOption = vi.fn(async () => undefined) store = await openTestAgentSessionRecordStore(root) + recoveryCapsule = new TrackedTestRecoveryCapsule(root) host = new StructuredAgentSessionHost({ store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), - recoveryCapsule: new AgentSessionRecoveryCapsule(root), + recoveryCapsule, claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW @@ -163,9 +166,16 @@ beforeEach(async () => { afterEach(async () => { await host.flushAllStreamedEvents() await journals.closeAll() + // A host a test replaced can still hold the capsule's lock directory under `root`. + await recoveryCapsule.settled() await rm(root, { recursive: true, force: true }) }) +/** Waits out every recovery-capsule operation a host of this test started, replaced ones too. */ +function hostTestRecoveryCapsuleSettled(): Promise { + return recoveryCapsule.settled() +} + /** A restarted process swaps the store and the host under the same directories. * The helpers here close over both, so they have to be told. */ export function replaceHostTestState(next: { @@ -176,6 +186,12 @@ export function replaceHostTestState(next: { host = next.host } +/** Serves `records` as the session's child records through the status sink, as the host's store + * does. Call before `attach`: only the attach's publish carries the address a row lands under. */ +export function serveHostTestChildWork(records: () => AgentChildWorkView[]): void { + host.deps.statusSink = { publish: () => {}, forget: () => {}, readChildWork: records } +} + /** The live per-test state. Read it in a `beforeEach` so a suite's test bodies * keep using bare `host` / `store` / `dispatch` exactly as they did when this * setup was inline. */ @@ -201,6 +217,7 @@ export { attachParams, ensureParams, envelope, + hostTestRecoveryCapsuleSettled, journals, seedApproval } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-recovery-capsule.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-recovery-capsule.ts new file mode 100644 index 000000000000..0319346a9ab1 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-recovery-capsule.ts @@ -0,0 +1,66 @@ +// The host harness's recovery capsule. A host a test replaced keeps its restart-offer lane running +// (a withdrawal is fire-and-forget), and nothing else waits for it, so cleanup waits here before it +// removes the directory whose lock that operation holds. + +import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' + +type Capsule = AgentSessionRecoveryCapsule + +export class TrackedTestRecoveryCapsule extends AgentSessionRecoveryCapsule { + private readonly running = new Set>() + + /** Until no operation runs, including one a finished operation's lane starts next. */ + async settled(): Promise { + do { + await Promise.allSettled(this.running) + await new Promise((resolve) => setImmediate(resolve)) + } while (this.running.size > 0) + } + + override list(...args: Parameters) { + return this.track(super.list(...args)) + } + + override listFailed(...args: Parameters) { + return this.track(super.listFailed(...args)) + } + + override record(...args: Parameters) { + return this.track(super.record(...args)) + } + + override beginResume(...args: Parameters) { + return this.track(super.beginResume(...args)) + } + + override completeResume(...args: Parameters) { + return this.track(super.completeResume(...args)) + } + + override failResume(...args: Parameters) { + return this.track(super.failResume(...args)) + } + + override dismiss(...args: Parameters) { + return this.track(super.dismiss(...args)) + } + + override forgetSuperseded(...args: Parameters) { + return this.track(super.forgetSuperseded(...args)) + } + + override rollbackResume(...args: Parameters) { + return this.track(super.rollbackResume(...args)) + } + + override clearAll(...args: Parameters) { + return this.track(super.clearAll(...args)) + } + + private track(operation: Promise): Promise { + this.running.add(operation) + const forget = () => this.running.delete(operation) + operation.then(forget, forget) + return operation + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 80ea85e86835..c2ec18332882 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -10,7 +10,9 @@ import type { JournalHostDatabase } from '../agent-session-journal/journal-host- import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter, - StructuredAgentSessionProviderChildPhase + StructuredAgentSessionChildEndCause, + StructuredAgentSessionProviderChildPhase, + StructuredAgentSessionStopCause } from './structured-agent-session-adapter' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' @@ -35,6 +37,11 @@ export type StructuredAgentSessionProviderChildIdentity = { readonly fence: number } +/** A wind-down still owed, with the cause of the stop that owes it: a retry finishes that stop. */ +export type StructuredAgentSessionOwedWindDown = StructuredAgentSessionProviderChildIdentity & { + readonly cause: StructuredAgentSessionStopCause +} + /** The provider process behind a conversation. Written only in * `structured-agent-session-provider-child`. */ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProviderChildIdentity & { @@ -50,12 +57,7 @@ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProvider * `stopAgentSessionProviderRoot`; an observed exit's root is gone by definition. */ export type StructuredAgentSessionStopVerdict = { rootGone: boolean } -export type StructuredAgentSessionChildEndCause = - | 'user-stop' - | 'host-stop' - | 'exit' - | 'attach-failed' - | 'evict' +export type { StructuredAgentSessionChildEndCause } /** How the conversation's last child ended. In memory only: the delivery loop reads it to tell a * Stop from a failure. */ @@ -87,7 +89,7 @@ export type StructuredAgentSessionHostSession = { /** The wind-down this host still owes for a child it started: settling that generation's work * and handing the lease back. Outlives `child`, which ends the moment the adapter proves the * exit — an eviction that aborts after that point must still finish it on the next close. */ - owesProviderChildWindDown?: StructuredAgentSessionProviderChildIdentity + owesProviderChildWindDown?: StructuredAgentSessionOwedWindDown lastEndedChild?: StructuredAgentSessionEndedChild } @@ -120,6 +122,10 @@ export type StructuredAgentSessionHostDeps = { /** Whether an orchestration dispatch still owns this session's worker; absent answers no. */ hasOpenDispatch?: (record: AgentSessionRecord) => boolean onEventSinkError?: (input: { sessionId: string; error: unknown }) => void + /** Lease bookkeeping run for startup or a read (the reconcile, or resolving a chat's recovery) + * that refused or threw, once per distinct failure. Startup and the read carry on: the next + * attach or send reconciles and resolves recovery again before it acts. */ + onLeaseReconcileFailure?: (failure: unknown) => void /** Every status projection this host publishes. `replay` marks a re-projection of state the host * already knew (restore, an arriving subscriber) rather than a fresh journal edge. */ onSessionStatusChanged?: ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 79a129118ee0..37f4a1966101 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -34,9 +34,9 @@ import type { StructuredAgentSessionAttachContext } from './structured-agent-ses import * as sessionTabs from './structured-agent-session-host-tabs' import { structuredAgentSessionMutationDelegates, - settleStructuredAgentSessionLateDispatch, type StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import { settleStructuredAgentSessionLateDispatch } from './structured-agent-session-late-dispatch' import { releaseStructuredAgentSessionUnansweredDispatches } from './structured-agent-session-unanswered-dispatch-release' import { flushStructuredAgentSessionHost } from './structured-agent-session-host-teardown' import type { @@ -81,7 +81,8 @@ export class StructuredAgentSessionHost { () => this.now(), () => this.deps, (sessionId) => this.queued.onJournalActivity(sessionId), - (sessionId) => this.restartResume.onAgentStarted(sessionId) + (sessionId) => this.restartResume.onAgentStarted(sessionId), + (sessionId) => this.backgroundTasks.publish(sessionId) ) private readonly subscribers = this.clientDelivery.subscribers private readonly tasks = new StructuredAgentSessionTaskQueue() @@ -100,12 +101,13 @@ export class StructuredAgentSessionHost { readonly restartResume: StructuredAgentSessionRestartResume constructor(readonly deps: StructuredAgentSessionHostDeps) { + this.clientDelivery.watchAtRestCommands(deps.adapter) this.backgroundTasks = new StructuredAgentSessionBackgroundTaskChannel( deps, this.sessions, this.subscribers, (sessionId) => this.lifetime.conversation(sessionId), - this.clientDelivery.publishStatus + this.clientDelivery.readChildWork ) this.runtimeState = new StructuredAgentSessionHostRuntimeState(deps, (sessionId, error) => this.eventRecovery.recoverAfterSinkFailure(sessionId, error) @@ -131,11 +133,11 @@ export class StructuredAgentSessionHost { reset, structuredAgentSessionConversationFence(deps.store, sessionId) ), - publishRestored: this.clientDelivery.publishRestored, + clientDelivery: this.clientDelivery, flushStreamedEvents: (sessionId) => this.flushStreamedEvents(sessionId) }) this.restore = createStructuredAgentSessionHostRestore(deps, { - reconcile: this.reconcileLeases, + reconcileLeases: this.reconcileLeases, resolveRecovery: (sessionId) => this.runtimeState.resolveRecovery(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), hasSession: this.hasSession, @@ -159,18 +161,18 @@ export class StructuredAgentSessionHost { now: () => this.now(), onBarrierError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }) }) - this.restartResume = createStructuredAgentSessionRestartResume( - deps, - this.sessions, - structuredAgentSessionRestartResumeSurfaces(this, this.now) - ) + this.restartResume = createStructuredAgentSessionRestartResume(deps, this.sessions, { + ...structuredAgentSessionRestartResumeSurfaces(this, this.now), + readChildWork: this.clientDelivery.readChildWork + }) this.lifetime = createStructuredAgentSessionConversationLifetime({ context: () => this.lifetimeContext(), sessions: this.sessions, serialize: (sessionId, task) => this.serialize(sessionId, task), open: (sessionId) => this.conversationDelivery.open(sessionId), deliveryActive: (sessionId) => this.conversationDelivery.loop.isRunning(sessionId), - closeStatus: (sessionId, options) => this.clientDelivery.closeSession(sessionId, options) + closeStatus: (sessionId, options) => this.clientDelivery.closeSession(sessionId, options), + readChildWork: this.clientDelivery.readChildWork }) this.runtimeState.startLeaseRenewal() this.lifetime.idleSweep.start() @@ -184,14 +186,15 @@ export class StructuredAgentSessionHost { handleAdapterEvent = (event: Parameters[0]) => this.eventRecovery.handle(event) - private lifetimeContext(): StructuredAgentSessionLifetimeContext { + // Inferred, so the attach context's spread keeps `publishStatus` required. + private lifetimeContext() { return { deps: this.deps, runtimeState: this.runtimeState, sessions: this.sessions, now: () => this.now(), publishStatus: this.clientDelivery.publishStatus - } + } satisfies StructuredAgentSessionLifetimeContext } /** The host's half of attaching, named so it cannot grow dependencies unnoticed. */ @@ -202,12 +205,13 @@ export class StructuredAgentSessionHost { tasks: this.tasks, reconcileLeases: (sessionId) => this.reconcileLeases(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), - publishStatus: this.clientDelivery.publishStatus, openConversation: this.conversationDelivery.open } } - /** Releases a session's resources without ending the conversation; see the lifetime's close. */ - close = (sessionId: string): Promise => this.lifetime.close(sessionId) + /** Releases a session's resources without ending the conversation; see the lifetime's close. + * `user-close` makes a turn it cuts short the user's cancellation; an `evict` leaves it news. */ + close: StructuredAgentSessionConversationLifetime['close'] = (sessionId, cause) => + this.lifetime.close(sessionId, cause) supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => providerSupport.adapterSupportsCreate(this.deps.adapter, location, agent) @@ -215,6 +219,9 @@ export class StructuredAgentSessionHost { listSessionTabs = () => sessionTabs.listStructuredAgentSessionTabs(this.sessions) getPersistedVisibleSessionTabIndex = () => this.deps.store.getVisibleSessionTabIndex() getSessionTabId = (sessionId: string): string | null => this.deps.store.getSessionTabId(sessionId) + showSessionTabs = (sessionIds: readonly string[]) => this.deps.store.showSessionTabs(sessionIds) + /** The records file could not be read this launch, so chats it holds are not listed yet. */ + legacyRecordImportOwed = (): boolean => this.deps.journalDatabase.legacyRecordImportOwed === true setSessionTabVisibility = async ( sessionId: string, @@ -228,12 +235,7 @@ export class StructuredAgentSessionHost { } } - reconcileRestartLeases = async (): Promise => { - const refusal = await this.reconcileLeases('startup') - if (refusal) { - throw new Error(refusal.code) - } - } + reconcileRestartLeases = (): Promise => this.restore.reconcileRestartLeases() restoreReadableSessions = (sessionIds?: readonly string[]): Promise => this.restore.restoreReadableSessions(sessionIds) @@ -275,12 +277,13 @@ export class StructuredAgentSessionHost { publish: (sessionId, journal) => this.subscribers.publish(sessionId, journal), flushStreamedEvents: this.flushStreamedEvents, conversation: this.lifetime.conversation, + readChildWork: this.clientDelivery.readChildWork, serialize: (sessionId, task) => this.serialize(sessionId, task), openConversation: this.conversationDelivery.open, ensureAgent: (sessionId) => ensureStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId), wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), - stopAgent: this.lifetime.stopAgent, + stopAgent: (sessionId) => this.lifetime.stopAgent(sessionId, 'user-stop'), wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId), now: () => this.now() } @@ -308,9 +311,7 @@ export class StructuredAgentSessionHost { this.conversationCommands.run(...args) conversationReplacements = () => this.conversationCommands.replacements() /** Undefined means unavailable; an empty array is an authoritative catalog. */ - readCommands = (sessionId: string): SessionWire.AgentSessionCommandsResult => ({ - commands: this.deps.adapter.readCommands?.(sessionId) - }) + readCommands = (sessionId: string) => ({ commands: this.clientDelivery.readCommands(sessionId) }) /** From the record store, never the session map: an idle-released chat has no map entry. */ handoffStatus = (sessionId: string): SessionWire.AgentSessionHandoffStatus => @@ -334,8 +335,6 @@ export class StructuredAgentSessionHost { input: Parameters[1] ) => releaseStructuredAgentSessionUnansweredDispatches(this.mutationContext(), input) - publishBackgroundTaskState: StructuredAgentSessionBackgroundTaskChannel['publish'] = (...args) => - this.backgroundTasks.publish(...args) publishChildWorkEvidence = this.clientDelivery.publishChildWork unsubscribe = (sessionId: string, id: string): void => this.subscribers.close(sessionId, id) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index 25288dc64905..cad1d17d94f5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -4,6 +4,7 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journa import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { collectSubscriber, createRestTestRig, @@ -43,6 +44,25 @@ function lastStatus(): AgentSessionStatusSummary | undefined { .findLast((summary) => summary.sessionId === SESSION) } +/** A child record as the host's store serves it; a `done` one has settled. */ +function childView( + id: string, + kind: AgentChildWorkView['kind'], + state: 'working' | 'idle' | 'done' +): AgentChildWorkView { + return { + id, + kind, + state, + membership: state === 'done' ? 'settled' : 'live', + ...(state === 'done' ? { outcome: 'succeeded' as const, settledAt: rig.clock.now } : {}), + firstObservedAt: rig.clock.now, + observedAt: rig.clock.now, + stoppable: false, + invocation: { invocationId: `spawn-${id}`, generation: 1 } + } +} + function fence(): number { return rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 1 } @@ -54,7 +74,7 @@ describe('the idle sweep', () => { await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) await vi.waitFor(() => expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released')) await vi.waitFor(() => expect(rig.adapter.acknowledgeSessionRelease).toHaveBeenCalledOnce()) expect(rig.adapter.acknowledgeSessionRelease).toHaveBeenCalledWith(SESSION) @@ -102,84 +122,59 @@ describe('the idle sweep', () => { it('never stops an agent whose background work still runs (P2-09)', async () => { await foundRestTestChat(rig) - // A Codex subagent thread is one of these tasks; the tracker projects both kinds alike. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a background roster fixture; the sweep reads only whether live tasks exist. - rig.adapter.backgroundTaskState.mockReturnValue({ - state: 'monitoring', - tasks: [ - { - taskId: 'subagent-1', - kind: 'subagent', - status: 'running', - title: 'reviewer', - startedAt: rig.clock.now - } - ] - } as never) + // A Codex subagent thread is one of these records; the host holds both kinds alike. + rig.sink.readChildWork.mockReturnValue([childView('subagent-1', 'agent', 'working')]) rig.clock.now += IDLE_MS + 1 await sweepTicks() expect(rig.adapter.closeSession).not.toHaveBeenCalled() - rig.adapter.backgroundTaskState.mockReturnValue(undefined) + rig.sink.readChildWork.mockReturnValue([]) rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) }) // A finished child reads done before the lead's wake-up turn writes its first row; stopping the // agent in that gap would lose the wake-up. Owed work is activity, as main's release clock had it. it('gives an agent a full idle window after its background work ends', async () => { await foundRestTestChat(rig) - rig.adapter.backgroundTaskState.mockReturnValue({ - state: 'monitoring', - tasks: [{ id: 'subagent-1', kind: 'agent', state: 'working' }] - }) + rig.sink.readChildWork.mockReturnValue([childView('subagent-1', 'agent', 'working')]) rig.clock.now += IDLE_MS + 1 await sweepTicks() - rig.adapter.backgroundTaskState.mockReturnValue({ - state: 'monitoring', - tasks: [{ id: 'subagent-1', kind: 'agent', state: 'done' }] - }) + rig.sink.readChildWork.mockReturnValue([childView('subagent-1', 'agent', 'done')]) await sweepTicks() expect(rig.adapter.closeSession).not.toHaveBeenCalled() rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) }) // Owed work is read every tick, not once a window: work that ends just before a window would // have closed still leaves the agent a full window after it. it('gives a full window after background work that ends late in a window', async () => { await foundRestTestChat(rig) - const subagent = (state: 'working' | 'done') => ({ - state: 'monitoring' as const, - tasks: [{ id: 'subagent-1', kind: 'agent' as const, state }] - }) - rig.adapter.backgroundTaskState.mockReturnValue(subagent('working')) + rig.sink.readChildWork.mockReturnValue([childView('subagent-1', 'agent', 'working')]) rig.clock.now += IDLE_MS + 1 await sweepTicks() rig.clock.now += IDLE_MS - 60_000 await sweepTicks() - rig.adapter.backgroundTaskState.mockReturnValue(subagent('done')) + rig.sink.readChildWork.mockReturnValue([childView('subagent-1', 'agent', 'done')]) rig.clock.now += 60_000 + 1 await sweepTicks() expect(rig.adapter.closeSession).not.toHaveBeenCalled() rig.clock.now += IDLE_MS - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) }) - it('stops an agent whose roster holds only children that went idle or finished', async () => { + it('stops an agent whose child records hold only children that went idle or finished', async () => { await foundRestTestChat(rig) - rig.adapter.backgroundTaskState.mockReturnValue({ - state: 'monitoring', - tasks: [ - { id: 'subagent-1', kind: 'agent', state: 'idle' }, - { id: 'command-1', kind: 'command', state: 'done' } - ] - }) + rig.sink.readChildWork.mockReturnValue([ + childView('subagent-1', 'agent', 'idle'), + childView('subagent-2', 'agent', 'done') + ]) rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) }) it('never stops an agent while its lead turn runs, however quiet (P2-10)', async () => { @@ -202,7 +197,7 @@ describe('the idle sweep', () => { await rig.host.subscribe({ id: 'on-screen', sessionId: SESSION, emit: reader.emit }) rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) expect(reader.events.some((event) => event.type === 'end')).toBe(false) }) @@ -221,7 +216,7 @@ describe('the idle sweep', () => { await sweepTicks() expect(rig.adapter.closeSession).not.toHaveBeenCalled() rig.clock.now += IDLE_MS - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) }) it('never stops a worker whose orchestration dispatch is open, and stops it once it settles (P2-19 i)', async () => { @@ -237,7 +232,24 @@ describe('the idle sweep', () => { expect(hasOpenDispatch).toHaveBeenCalledWith(expect.objectContaining({ sessionId: SESSION })) open = false rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) + }) + + // A Claude retrying a rate-limited request has taken the send but echoes nothing, so no turn row + // exists yet; only the provider can say it still holds the send. + it('never stops an agent while its provider holds a send, and gives a full window once it lets go', async () => { + await foundRestTestChat(rig) + rig.adapter.holdsDispatch.mockReturnValue(true) + rig.clock.now += 2 * IDLE_MS + + await sweepTicks(12) + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + expect(rig.adapter.holdsDispatch).toHaveBeenCalledWith(SESSION) + rig.adapter.holdsDispatch.mockReturnValue(false) + await sweepTicks() + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + rig.clock.now += IDLE_MS + 1 + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) }) it('keeps a child an unanswered prompt waits on (P2-22 i)', async () => { @@ -302,8 +314,9 @@ describe('the idle sweep with no child running (P2-22 ii)', () => { now: () => IDLE_MS + 1, isDisposed: () => false, deliveryActive: () => false, - backgroundTaskState: () => undefined, + childWork: () => undefined, hasOpenDispatch: () => false, + providerHoldsDispatch: () => false, stopAgent, stopStartingAgent: stopAgent, closeConversation, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index 1fc223808f6b..14cc0e56030d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -12,7 +12,7 @@ import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work- import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' -import type { AgentSessionBackgroundTaskState } from '../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' export const STRUCTURED_AGENT_SESSION_IDLE_SWEEP_INTERVAL_MS = 5 * 60_000 @@ -27,9 +27,12 @@ export type StructuredAgentSessionIdleSweepDeps = { now: () => number isDisposed: () => boolean deliveryActive: (sessionId: string) => boolean - backgroundTaskState: (sessionId: string) => AgentSessionBackgroundTaskState | null | undefined + /** The session's child records, the host's one read of them. */ + childWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined /** An orchestration dispatch that still owns this session's worker; derived from its database. */ hasOpenDispatch: (sessionId: string) => boolean + /** The provider holds a send it took and has not answered; see `holdsDispatch`. */ + providerHoldsDispatch: (sessionId: string) => boolean /** Each of these runs inside the session's serialize and never takes it again. */ stopAgent: (sessionId: string) => Promise stopStartingAgent: (sessionId: string) => Promise @@ -143,8 +146,9 @@ export class StructuredAgentSessionIdleSweep { return ( activeStructuredAgentSessionTurnId(items) !== null || this.queuedOrDelivering(sessionId, session) || - agentChildWorkLiveness(this.deps.backgroundTaskState(sessionId)?.tasks) !== null || + agentChildWorkLiveness(this.deps.childWork(sessionId)) !== null || this.deps.hasOpenDispatch(sessionId) || + this.deps.providerHoldsDispatch(sessionId) || hasPendingStructuredAgentSessionPrompt(items) ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts index 3fb865f50ec7..ac4bb5e2f4d4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts @@ -3,39 +3,57 @@ import { readdir } from 'node:fs/promises' import { afterEach, beforeEach, expect, it, vi, type Mock } from 'vitest' -import { JournalHostDatabase } from '../agent-session-journal/journal-host-database' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentSessionHost } from './structured-agent-session-host' import { attach, CALLER, envelope, + hostTestRecoveryCapsuleSettled, hostTestState } from './structured-agent-session-host-test-harness' import { hostTestMessage } from './structured-agent-session-host-test-data' let root: string +let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let cancelTurn: Mock beforeEach(() => { - ;({ root, host, cancelTurn } = hostTestState()) + ;({ root, store, host, cancelTurn } = hostTestState()) }) afterEach(() => vi.restoreAllMocks()) +const sqliteError = (message: string, errcode: number): Error => + Object.assign(new Error(message), { code: 'ERR_SQLITE_ERROR', errcode }) + +/** Every statement on the chat records' tables fails; the history beside them still works. */ +function failRecordStatements(error: Error): void { + const connection = openTestJournalHostDatabase(root).db + const prepare = connection.prepare.bind(connection) + vi.spyOn(connection, 'prepare').mockImplementation((sql: string) => { + if (sql.includes('agent_session_')) { + throw error + } + return prepare(sql) + }) +} + +const stop = (turnEnvelope = envelope('agentSession.cancel', { turnId: 'turn-1' })) => + host.cancel(CALLER, { envelope: turnEnvelope, turnId: 'turn-1' }) + // T-corrupt-midsession. it('refuses a send as corrupt when SQLite reports damage, and still stops the agent', async () => { await attach() const files = await readdir(root, { recursive: true }) - const damaged = Object.assign(new Error('database disk image is malformed'), { - code: 'ERR_SQLITE_ERROR', - errcode: 11 - }) - vi.spyOn(JournalHostDatabase.prototype, 'transaction').mockImplementation(() => { + const damaged = sqliteError('database disk image is malformed', 11) + vi.spyOn(openTestJournalHostDatabase(root), 'transaction').mockImplementation(() => { throw damaged }) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const body = hostTestMessage('after the damage') const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) @@ -48,14 +66,55 @@ it('refuses a send as corrupt when SQLite reports damage, and still stops the ag details: { reason: 'journalCorrupt' } } }) - // Stop reaches the agent before it writes anything; the note it then cannot record is the - // error the caller sees, after the fact. - await expect( - host.cancel(CALLER, { - envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }), - turnId: 'turn-1' - }) - ).rejects.toBe(damaged) + // Stop reaches the agent without its ledger row; the note it then cannot record is the error + // the caller sees, after the fact. + await expect(stop()).rejects.toBe(damaged) expect(cancelTurn).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith("[agent-session] Stop's ledger row skipped:", { + sessionId: expect.any(String), + error: 'database disk image is malformed' + }) + // The restart-offer withdrawal the attach started holds its lock until it ends. + await hostTestRecoveryCapsuleSettled() expect(await readdir(root, { recursive: true })).toEqual(files) }) + +it.each([ + ['damaged', sqliteError('database disk image is malformed', 11)], + ['full', sqliteError('database or disk is full', 13)] +])('stops the agent when the records are %s and the history is not', async (_, error) => { + await attach() + failRecordStatements(error) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect(stop()).resolves.toMatchObject({ + ok: true, + replayed: false, + value: { turnId: 'turn-1', cancelled: true } + }) + expect(cancelTurn).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith("[agent-session] Stop's ledger row skipped:", { + sessionId: expect.any(String), + error: error.message + }) +}) + +it('replays a recorded Stop from memory when its ledger cannot be written', async () => { + await attach() + const stopEnvelope = envelope('agentSession.cancel', { turnId: 'turn-1' }) + await expect(stop(stopEnvelope)).resolves.toMatchObject({ ok: true, replayed: false }) + expect(cancelTurn).toHaveBeenCalledTimes(1) + // A replay writes nothing on a healthy store; one that refuses every transaction still throws. + vi.spyOn(store, 'admitMutationOperation').mockRejectedValue( + sqliteError('database disk image is malformed', 11) + ) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + // Interrupting twice would stop a turn the client never asked to stop. + await expect(stop(stopEnvelope)).resolves.toMatchObject({ + ok: true, + replayed: true, + value: { turnId: 'turn-1', cancelled: false } + }) + expect(cancelTurn).toHaveBeenCalledTimes(1) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts new file mode 100644 index 000000000000..ba0ab9231261 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts @@ -0,0 +1,49 @@ +import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' +import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' + +/** Settle a provider-proven outcome independently of an in-flight client mutation. `unknown` is + * released doubt: the provider let the send go unanswered, so nothing re-sends it. */ +export async function settleStructuredAgentSessionLateDispatch( + context: StructuredAgentSessionMutationContext, + input: { + sessionId: string + clientMessageId: string + } & ( + | { providerIdentity: AgentJournalItemIdentity } + | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | { state: 'unknown'; reason: string } + ) +): Promise { + const session = context.sessions.get(input.sessionId) + if (!session) { + return + } + const fence = structuredAgentSessionConversationFence(context.deps.store, input.sessionId) + // The journal queue drains before close; the host queue would defer this past teardown. + await session.journal.resolveDispatch( + 'providerIdentity' in input + ? { + clientMessageId: input.clientMessageId, + state: 'accepted', + providerIdentity: input.providerIdentity, + fence + } + : input.state === 'unknown' + ? { + clientMessageId: input.clientMessageId, + state: 'unknown', + reason: input.reason, + fence, + recovered: true + } + : { + clientMessageId: input.clientMessageId, + state: 'rejected', + reason: input.reason, + rejection: input.rejection, + fence + } + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index 510384f3e484..44615a27ee82 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -129,7 +129,7 @@ beforeEach(async () => { afterEach(async () => { await host.flushAllStreamedEvents() - await host.close(SESSION) + await host.close(SESSION, 'evict') await rm(root, { recursive: true, force: true }) }) @@ -202,7 +202,7 @@ describe('settling a send the provider proves it received after the ack window', return true }) - await host.close(SESSION) + await host.close(SESSION, 'evict') await expect(settlement).resolves.toBeUndefined() await host.revealSession(SESSION) expect(await submissions()).toMatchObject([{ dispatchState: 'accepted' }]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts index a61e11b6f4f1..21d790202709 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts @@ -36,8 +36,8 @@ export class StructuredAgentSessionLeaseRenewer { } /** Clearing the interval only stops the NEXT tick. A tick already past its guard still has a - * store transaction to commit, and that transaction re-creates the store directory, so a stop - * that returned before it landed would let the write outlive whatever tore the host down. */ + * store transaction to commit, so a stop that returned before it landed would let the write + * outlive whatever tore the host down. */ stop(): Promise { if (this.timer) { clearInterval(this.timer) @@ -91,8 +91,8 @@ export class StructuredAgentSessionLeaseRenewer { now }) } - // The store persists the whole record file per transaction, so keep the healthy path to - // one commit. If one renewal is superseded, retrying individually preserves isolation. + // One transaction for every renewal on the healthy path. If one renewal is superseded, + // retrying individually preserves isolation. let results: PromiseSettledResult[] try { const renewed = await this.input.store.renewLeases(renewals) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts index 333b5bf3e019..aa4a95470011 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts @@ -8,12 +8,12 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionBackgroundTaskState } from '../../../shared/agent-session-background-task-wire' import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalItemBody } from '../../../shared/agent-session-journal-types' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' @@ -46,7 +46,8 @@ let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let dispatch: Mock let events: StructuredAgentSessionEventSink | undefined -let backgroundTasks: AgentSessionBackgroundTaskState | null +/** The session's child records, as the host's store serves them to every surface. */ +let records: AgentChildWorkView[] let chat: StructuredAgentSessionState let listed: AgentSessionStatusSummary | undefined @@ -54,7 +55,7 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-main-agent-working-')) resetHostTestOperationIds() events = undefined - backgroundTasks = null + records = [] chat = EMPTY_STRUCTURED_AGENT_SESSION listed = undefined // Written, and the provider has neither opened a turn for it nor answered it. @@ -88,9 +89,9 @@ beforeEach(async () => { releaseAcquisition: vi.fn(async () => true), cancelTurn: vi.fn(async () => ({ cancelled: true })), answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined), - backgroundTaskState: () => backgroundTasks + setOption: vi.fn(async () => undefined) }, + statusSink: { publish: () => {}, forget: () => {}, readChildWork: () => records }, journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-1', @@ -163,7 +164,11 @@ function listReads(): { mainAgent: string; row: string } { if (!summary?.status) { return { mainAgent: 'none', row: 'none' } } - const status = structuredAgentSessionAgentStatus({ ...summary, status: summary.status }) + const status = structuredAgentSessionAgentStatus({ + ...summary, + status: summary.status, + childWork: summary.children + }) return { mainAgent: status.mainAgent.state, row: status.state } } @@ -197,14 +202,24 @@ describe('the main agent read by the chat and by a session list', () => { expect(listReads().mainAgent).toBe('working') await provider(1, { kind: 'turn', turnId: 'turn-1', state: 'completed', outcome: 'success' }) - backgroundTasks = { - state: 'monitoring', - tasks: [{ id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working' }] - } - host.publishBackgroundTaskState(SESSION) + records = [ + { + id: 'child-1', + providerId: 'task-1', + kind: 'agent', + name: 'deep_review', + state: 'working', + membership: 'live', + firstObservedAt: NOW, + observedAt: NOW, + stoppable: false, + invocation: { invocationId: 'task-1', generation: 1 } + } + ] + host.publishChildWorkEvidence(SESSION, []) await host.flushStreamedEvents(SESSION) - expect(chat.backgroundTasks?.tasks).toHaveLength(1) + expect(chat.backgroundTasks?.children).toHaveLength(1) // The row reads Working for the subagent; the main agent, and so Stop, does not. expect(listReads()).toEqual({ mainAgent: 'done', row: 'working' }) expect(chatReadsWorking()).toBe(false) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index 6a623428e4f0..3e96a02ce26a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -21,8 +21,17 @@ import { type AgentSessionMutationResult, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { AGENT_SESSION_UNATTACHED_REFUSAL_CODE } from '../../../shared/structured-agent-session-read-refusal' +import type { + AgentSessionMutationOperationAdmission, + AgentSessionMutationOperationDecision +} from '../../runtime/agent-session-operation-admission' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { + classifyJournalOpenFailure, + journalOpenRefusal +} from '../agent-session-journal/journal-open-failure' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { MutationPlan } from './structured-agent-session-mutation-plans' @@ -104,14 +113,32 @@ export async function admitAndRunAgentSessionMutation( if (!journal) { return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED) } - const admitted = await request.store.admitMutationOperation({ + const operation: AgentSessionMutationOperationAdmission = { callerKey: request.callerKey, envelope, hostFingerprint, now: request.now(), ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {}), ...(plan.conversationWrite ? { conversationWrite: true } : {}) - }) + } + let admitted: AgentSessionMutationOperationDecision + let ledgerRowWritten = true + try { + admitted = await request.store.admitMutationOperation(operation) + } catch (error) { + if (plan.runsWithoutLedgerRow) { + admitted = admitWithoutLedgerRow(request.store, operation, error) + ledgerRowWritten = false + } else if ( + isAgentSessionRefusalError(error) || + classifyJournalOpenFailure(error) === 'journalCorrupt' + ) { + // A store refusing the row (a newer Orca's records) or damage SQLite proves: as an open says. + return refuseAgentSessionMutation(journalOpenRefusal(error)) + } else { + throw error + } + } if (!admitted) { return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED) } @@ -152,20 +179,47 @@ export async function admitAndRunAgentSessionMutation( } } - const outcome = await runSettledAgentSessionMutation({ - store: request.store, - // A global send replay can cross caller identities. Settlement still owns - // the durable row admitted by the original caller. - operationCallerKey: admission.row.callerKey, - envelope, - plan, - context - }) + // With no row, a settle fails on the same storage and turns a landed Stop into a throw. + const outcome = ledgerRowWritten + ? await runSettledAgentSessionMutation({ + store: request.store, + // A global send replay can cross caller identities. Settlement still owns + // the durable row admitted by the original caller. + operationCallerKey: admission.row.callerKey, + envelope, + plan, + context + }) + : await plan.run(context) return outcome.ok ? { ok: true, replayed: false, fence, cursor: journal.cursor(), value: outcome.value } : refuseAgentSessionMutation(outcome.refusal) } +/** The committed ledger's admission, placing nothing: a failed commit left memory as it was. */ +function admitWithoutLedgerRow( + store: AgentSessionRecordStore, + operation: AgentSessionMutationOperationAdmission, + error: unknown +): AgentSessionMutationOperationDecision { + console.warn("[agent-session] Stop's ledger row skipped:", { + sessionId: operation.envelope.sessionId, + error: error instanceof Error ? error.message : String(error) + }) + const evaluated = store.evaluateMutationOperation(operation) + if (!evaluated) { + return null + } + const admission = admitAgentSessionMutation({ + envelope: operation.envelope, + hostFingerprint: operation.hostFingerprint, + ledger: evaluated.decision, + lease: evaluated.record.lease, + ...(operation.conversationWrite ? { conversationWrite: true } : {}) + }) + return { admission, record: evaluated.record } +} + function turnContext( request: AgentSessionMutationRequest, journal: AgentSessionJournal, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts new file mode 100644 index 000000000000..73b47f1fe6e2 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -0,0 +1,69 @@ +// The context every client mutation of a session runs with, and the one path each takes: admit the +// envelope against the lease, then run its plan inside the session's serialize. + +import type { + AgentSessionMutationEnvelope, + AgentSessionMutationResult +} from '../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import { + admitAndRunAgentSessionMutation, + type AgentSessionMutationRequest, + type AgentSessionMutationSessionPreparation +} from './structured-agent-session-mutation-admission' +import type { MutationPlan } from './structured-agent-session-mutation-plans' +import type { + StructuredAgentSessionCaller, + StructuredAgentSessionHostDeps, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' + +export type StructuredAgentSessionMutationContext = { + deps: StructuredAgentSessionHostDeps + sessions: Map + publish: (sessionId: string, journal: StructuredAgentSessionHostSession['journal']) => void + flushStreamedEvents: (sessionId: string) => Promise + /** The host's accessor, for a caller outside the session's serialize. */ + conversation: (sessionId: string) => Promise + /** The session's child records, as the strip reads them; what command admission decides on. */ + readChildWork: (sessionId: string) => AgentChildWorkView[] | undefined + serialize: (sessionId: string, task: () => Promise) => Promise + /** The session's conversation, opened when closed; inside the caller's serialize. */ + openConversation: (sessionId: string) => Promise + /** Gives the session a provider child; inside the caller's serialize. */ + ensureAgent: (sessionId: string) => Promise + /** A message was accepted: the session's delivery loop hands it over. */ + wakeDelivery: (sessionId: string) => void + /** Stops the session's provider child, keeping its conversation; inside the caller's serialize. */ + stopAgent: (sessionId: string) => Promise + /** Only for gate inputs living in the RECORD store, which can settle with no + * journal commit (a conversation command). Draft-table changes need no call: + * the draft store notifies through the journal's own commit listener. */ + wakeQueuedDrain?: (sessionId: string) => void + now: () => number +} + +/** Admits the envelope and runs the plan inside the session's serialize. */ +export function mutateStructuredAgentSession( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + envelope: AgentSessionMutationEnvelope, + plan: MutationPlan, + prepareSession?: AgentSessionMutationRequest['prepareSession'] +): Promise> { + return context.serialize(envelope.sessionId, () => + admitAndRunAgentSessionMutation({ + store: context.deps.store, + adapter: context.deps.adapter, + callerKey: caller.callerKey, + envelope, + plan, + journal: () => context.sessions.get(envelope.sessionId)?.journal, + prepareSession, + publish: (journal) => context.publish(envelope.sessionId, journal), + flushStreamedEvents: context.flushStreamedEvents, + providerChildPhase: () => context.sessions.get(envelope.sessionId)?.child?.phase, + now: () => context.now() + }) + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index 388fabd7bd45..a81ea5270c88 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -6,6 +6,7 @@ // journal. Send is fail-closed: admission alone cannot prove non-delivery. import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' import type { AgentSessionCancelResult, @@ -48,6 +49,8 @@ export type MutationPlan = { operationIdScope?: 'global' /** Admitted without the writer lease: see `admitAgentSessionMutation`. */ conversationWrite?: true + /** Still runs, decided from the committed ledger, when its ledger row cannot be written. */ + runsWithoutLedgerRow?: true markUnknownBeforeRun?: boolean run: (ctx: AgentSessionTurnContext) => Promise> replay: (ctx: AgentSessionTurnContext, outcome: AgentSessionOperationOutcome) => TValue | null @@ -170,12 +173,15 @@ export function cancelPlan(params: { scope?: 'background-tasks' taskId?: string prompt?: { itemId: string; expectedRevision: number } - stopChild?: () => Promise + /** The session's child records, which name the tasks a background Stop reaches. */ + childWork?: () => readonly AgentChildWorkView[] | undefined }): MutationPlan { return { method: 'agentSession.cancel', // Stop is a conversation write; a prompt or background-task cancel needs the live child. ...(params.scope || params.prompt ? {} : { conversationWrite: true as const }), + // A Stop must reach the agent even when storage refuses the row recording it. + runsWithoutLedgerRow: true, fields: { ...(params.turnId !== undefined ? { turnId: params.turnId } : {}), ...(params.scope ? { scope: params.scope } : {}), @@ -189,7 +195,7 @@ export function cancelPlan(params: { ...(params.scope ? { scope: params.scope } : {}), ...(params.taskId ? { taskId: params.taskId } : {}), ...(params.prompt ? { prompt: params.prompt } : {}), - ...(params.stopChild ? { stopChild: params.stopChild } : {}) + ...(params.childWork ? { childWork: params.childWork } : {}) }), // Interrupting twice would kill a turn the client never asked to stop, so a // replay reports the turn as already handled. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-models.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-models.ts new file mode 100644 index 000000000000..1c8ec0baa4bf --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-models.ts @@ -0,0 +1,19 @@ +// The models a session's options answer lists, the same whether a child answers live or the host +// answers for a chat at rest: the catalog's rows, plus the current model when the catalog does not +// list it. Nothing says which efforts an unlisted model takes, so its row offers none, and a client +// shows no effort control for it. + +import type { AgentSessionModelOption } from '../../../shared/agent-session-wire' + +/** `row` builds a provider's own row type from the shared one. */ +export function structuredAgentSessionOptionModels( + catalog: readonly TModel[], + current: string | undefined, + row: (unlisted: AgentSessionModelOption) => TModel +): TModel[] { + const models = [...catalog] + if (current && !models.some((entry) => entry.id === current)) { + models.push(row({ id: current, label: current, isDefault: false, efforts: [] })) + } + return models +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts index 1ec8f2784a28..aa7b8a17295f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts @@ -212,9 +212,9 @@ describe('structured session options and close', () => { it('stops the provider child and forgets the session when the chat closes', async () => { expect(host.hasSession(SESSION)).toBe(true) - await host.close(SESSION) + await host.close(SESSION, 'evict') - expect(closeNativeSession).toHaveBeenCalledWith(SESSION) + expect(closeNativeSession).toHaveBeenCalledWith(SESSION, 'evict') expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', ownerProcess: null, @@ -222,15 +222,15 @@ describe('structured session options and close', () => { }) expect(host.hasSession(SESSION)).toBe(false) - await expect(host.close(SESSION)).resolves.toBeUndefined() + await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() expect(closeNativeSession).toHaveBeenCalledOnce() }) it('is a no-op for a session it does not hold', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') closeNativeSession.mockClear() - await expect(host.close(SESSION)).resolves.toBeUndefined() + await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() expect(closeNativeSession).not.toHaveBeenCalled() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index 3bad8d9789f1..106998ccfed5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -7,20 +7,33 @@ import { refuse, + type AgentSessionModelOption, type AgentSessionOptionResult, type AgentSessionOptionsResult } from '../../../shared/agent-session-wire' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { claudeFallbackModelOptions } from '../../claude/claude-structured-session-options' import { decodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { journalOpenReadRefusal } from '../agent-session-journal/journal-open-failure' import { isClaudeStructuredOptionKey } from '../../claude/claude-structured-options' import { isCodexTurnOptionKey } from '../../codex/codex-structured-turn-start' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import { structuredAgentSessionOptionModels } from './structured-agent-session-option-models' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' type RestingOptions = Pick +/** With no catalog for the account, the list a running child falls back to: Claude's built-in + * models. A Codex child has no such list, so none: the client fills the current model from its + * own unknown-model defaults, unchanged from before this list was shared. */ +function restingFallbackModels( + provider: AgentSessionRecord['provider'] +): AgentSessionModelOption[] | null { + return provider === 'claude' ? claudeFallbackModelOptions() : null +} + async function readStructuredAgentSessionOptionsAtRest( deps: Pick, sessionId: string @@ -32,14 +45,20 @@ async function readStructuredAgentSessionOptionsAtRest( const catalog = (await deps.modelCatalog ?.read({ agent: record.provider, sessionId }) .catch(() => null)) ?? { origin: 'unknown' as const } - const models = catalog.origin === 'unknown' ? [] : catalog.models + const listed = + catalog.origin === 'unknown' ? restingFallbackModels(record.provider) : catalog.models + const models = listed ?? [] const saved = record.options ?? {} const fastMode = saved.fastMode === undefined ? null : decodeStructuredAgentSessionOptionValue('fastMode', saved.fastMode) - // An unknown model is one the client already treats as unconfirmed. - const model = saved.model ?? models.find((entry) => entry.isDefault)?.id ?? '' + // An unknown model is one the client already treats as unconfirmed. Only a real listing names the + // account's default; a built-in list's default is a guess, so with none the client keeps its own. + const model = + saved.model ?? + (catalog.origin === 'unknown' ? undefined : models.find((entry) => entry.isDefault)?.id) ?? + '' // As a live child answers: the pick, else what Claude runs for this model when none is sent. // A live Codex child answers only the effort its thread reported, never the model's default. const effort = @@ -48,7 +67,7 @@ async function readStructuredAgentSessionOptionsAtRest( ? models.find((entry) => entry.id === model)?.defaultEffort : undefined) return { - models, + models: listed ? structuredAgentSessionOptionModels(listed, model, (row) => row) : [], ...(catalog.origin !== 'unknown' && catalog.fastModeSupport ? { fastModeSupport: catalog.fastModeSupport } : {}), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts index 6ad1506e2aa3..24e9dc66f446 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts @@ -13,6 +13,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { AgentHookServer, _internals } from '../../agent-hooks/server' import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { fakeClaude, @@ -44,9 +45,11 @@ let claude: ReturnType let landInit: () => void let lifecycle: Promise[] let clock: number +const server = new AgentHookServer() beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-owed-work-release-')) + _internals.resetCachesForTests() resetHostTestOperationIds() claude = fakeClaude() lifecycle = [] @@ -73,6 +76,8 @@ beforeEach(async () => { }, // As the runtime wires it: an admitted prompt's outcome reaches the journal out of band. onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + onChildWorkEvidence: (sessionId, evidence) => + host.publishChildWorkEvidence(sessionId, evidence), // Initialize answers only when the test says so. openConnection: async (launch, handlers) => { const connection = await claude.openConnection(launch, handlers) @@ -94,7 +99,15 @@ beforeEach(async () => { claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', idleSweep: { intervalMs: SWEEP_MS, idleMs: IDLE_MS }, - now: () => clock + now: () => clock, + // The host's status row and child records, as the runtime wires them. + statusSink: { + publish: (summary, subject) => server.ingestStructuredStatus(summary, subject), + forget: (subject) => server.dropStructuredStatus(subject), + publishChildWork: (subject, evidence, provider) => + server.ingestStructuredChildWork(subject, evidence, provider), + readChildWork: (subject) => server.getStructuredChildWorkViews(subject) + } }) }) @@ -263,8 +276,9 @@ describe('a chat whose settled lead still has background work running', () => { }) frame({ type: 'result', subtype: 'success', uuid: 'result-1', is_error: false, result: 'ok' }) await host.flushStreamedEvents(SESSION) - expect(adapter.backgroundTaskState(SESSION)?.tasks).toEqual([ - expect.objectContaining({ id: 'task-1' }) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok ? page.page.backgroundTasks?.children : null).toEqual([ + expect.objectContaining({ providerId: 'task-1', membership: 'live' }) ]) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts index b9a7bc7c5c47..6e0a44e60cac 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts @@ -6,8 +6,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { + AgentSessionPromptUnavailableError, + type StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' +import { cancelStructuredAgentSessionPrompt } from './structured-agent-session-prompt-cancel' +import type { AgentSessionPromptCancelRoute } from './structured-agent-session-adapter-stop' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -34,16 +39,27 @@ afterEach(async () => { } }) -async function pendingPrompt(): Promise<{ journal: AgentSessionJournal; itemId: string }> { +async function pendingPrompt( + options = [{ id: 'allow', label: 'Allow' }], + /** Raise the card in a turn that is still running, rather than on the conversation. */ + inLiveTurn = false +): Promise<{ journal: AgentSessionJournal; itemId: string }> { root = await mkdtemp(join(tmpdir(), 'orca-prompt-cancel-')) const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const turn = inLiveTurn + ? await journal.appendItem( + { ...PROMPT_IDENTITY, ordinal: 0 }, + { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 1 }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + : null const item = await journal.appendItem( PROMPT_IDENTITY, { kind: 'approval', title: 'Approve?', detail: null, - options: [{ id: 'allow', label: 'Allow' }], + options, resolution: { state: 'pending', selectedOptionId: null, @@ -51,7 +67,10 @@ async function pendingPrompt(): Promise<{ journal: AgentSessionJournal; itemId: resolvedAt: null } }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { + fence: 1, + turnScope: turn ? { kind: 'turn', turnItemId: turn.itemId } : AGENT_JOURNAL_THREAD_SCOPE + } ) return { journal, itemId: item.itemId } } @@ -212,3 +231,141 @@ describe('performCancel for a pending prompt', () => { expect(journal.snapshot().items).toHaveLength(1) }) }) + +describe("a card's own Cancel, as its provider answers it", () => { + async function cancelCard( + answer: AgentSessionPromptCancelRoute | undefined, + revision = 1, + endsSession = true, + inLiveTurn = true + ) { + const { journal, itemId } = await pendingPrompt( + [ + { id: 'allow', label: 'Allow' }, + { id: 'deny', label: 'Deny' } + ], + inLiveTurn + ) + const ctx = context( + journal, + vi.fn(async () => ({ cancelled: true })), + vi.fn(async () => undefined) + ) + const answerPrompt = vi.fn(async (input) => { + await input.commit() + }) + const dismissPrompt = vi.fn>( + async (input) => { + await input.commit() + } + ) + Object.assign(ctx.adapter, { answerPrompt, dismissPrompt, routePromptCancel: () => answer }) + const routes = { + stop: vi.fn(async () => ({ + outcome: { ok: true as const, value: { cancelled: endsSession } }, + endsSession + })), + interrupt: vi.fn(async () => ({ ok: true as const, value: { cancelled: true } })) + } + const result = await cancelStructuredAgentSessionPrompt( + ctx, + { turnId: 'turn-1', prompt: { itemId, expectedRevision: revision } }, + routes + ) + const card = journal.snapshot().items.find((item) => item.itemId === itemId)?.body + return { result, routes, answerPrompt, dismissPrompt, card } + } + + it('interrupts the turn holding the card for a provider that gives no answer', async () => { + const { routes, answerPrompt } = await cancelCard(undefined) + + expect(routes.interrupt).toHaveBeenCalledOnce() + expect(routes.stop).not.toHaveBeenCalled() + expect(answerPrompt).not.toHaveBeenCalled() + }) + + it('records a dismissal as cancelled by the caller and has the provider decline the request', async () => { + const { result, routes, answerPrompt, dismissPrompt, card } = await cancelCard({ + kind: 'dismiss' + }) + + expect(result).toEqual({ ok: true, value: { turnId: 'turn-1', cancelled: true } }) + expect(dismissPrompt).toHaveBeenCalledWith(expect.objectContaining({ answer: true })) + expect(card).toMatchObject({ + resolution: { state: 'cancelled', selectedOptionId: null, resolvedBy: 'client-1' } + }) + expect(answerPrompt).not.toHaveBeenCalled() + expect(routes.stop).not.toHaveBeenCalled() + }) + + it("runs the chat's Stop and settles the card in its step, leaving the request to the child's end", async () => { + const { result, routes, answerPrompt, dismissPrompt, card } = await cancelCard({ + kind: 'stop' + }) + + expect(result).toEqual({ ok: true, value: { turnId: 'turn-1', cancelled: true } }) + expect(routes.stop).toHaveBeenCalledOnce() + expect(routes.interrupt).not.toHaveBeenCalled() + expect(dismissPrompt).toHaveBeenCalledWith(expect.objectContaining({ answer: false })) + expect(answerPrompt).not.toHaveBeenCalled() + expect(card).toMatchObject({ resolution: { state: 'cancelled', resolvedBy: 'client-1' } }) + }) + + it('declines the request itself when the Stop ends nothing', async () => { + const { result, dismissPrompt, card } = await cancelCard({ kind: 'stop' }, 1, false) + + expect(result).toEqual({ ok: true, value: { turnId: 'turn-1', cancelled: true } }) + expect(dismissPrompt).toHaveBeenCalledWith(expect.objectContaining({ answer: true })) + expect(card).toMatchObject({ resolution: { state: 'cancelled', resolvedBy: 'client-1' } }) + }) + + it('still records the cancel when the provider already let the request go', async () => { + const { journal, itemId } = await pendingPrompt() + const ctx = context( + journal, + vi.fn(async () => ({ cancelled: true })), + vi.fn(async () => undefined) + ) + Object.assign(ctx.adapter, { + dismissPrompt: async () => { + throw new AgentSessionPromptUnavailableError(itemId) + }, + routePromptCancel: () => ({ kind: 'dismiss' }) + }) + + await expect( + cancelStructuredAgentSessionPrompt( + ctx, + { turnId: 'turn-1', prompt: { itemId, expectedRevision: 1 } }, + { stop: vi.fn(), interrupt: vi.fn() } + ) + ).resolves.toMatchObject({ ok: true }) + expect(journal.snapshot().items.find((item) => item.itemId === itemId)?.body).toMatchObject({ + resolution: { state: 'cancelled', resolvedBy: 'client-1' } + }) + }) + + it('dismisses a card a turn that is no longer live raised, and stops nothing', async () => { + const { result, routes, dismissPrompt, card } = await cancelCard( + { kind: 'stop' }, + 1, + true, + false + ) + + expect(result).toEqual({ ok: true, value: { turnId: 'turn-1', cancelled: true } }) + expect(routes.stop).not.toHaveBeenCalled() + expect(dismissPrompt).toHaveBeenCalledWith(expect.objectContaining({ answer: true })) + expect(card).toMatchObject({ resolution: { state: 'cancelled', resolvedBy: 'client-1' } }) + }) + + it('refuses a card that moved on before choosing a route', async () => { + const { result, routes } = await cancelCard({ kind: 'stop' }, 2) + + expect(result).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_item_revision_stale' } + }) + expect(routes.stop).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.ts new file mode 100644 index 000000000000..5e3272b4252c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.ts @@ -0,0 +1,146 @@ +// A prompt card's own Cancel, routed the way its provider says: a dismissal, or the chat's Stop. A +// provider that says nothing interrupts the turn holding the card. The host decides, so a client of +// any version gets the same Cancel. + +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { refuse, type AgentSessionCancelResult } from '../../../shared/agent-session-wire' +import { AgentSessionPromptUnavailableError } from './structured-agent-session-adapter' +import type { StructuredAgentSessionChatStopRun } from './structured-agent-session-chat-stop' +import { + validatePendingPrompt, + type PendingPromptValidation +} from './structured-agent-session-prompt-state' +import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' + +type CancelOutcome = TurnOutcome +type PendingPrompt = Extract + +export async function cancelStructuredAgentSessionPrompt( + ctx: AgentSessionTurnContext, + input: { turnId?: string; prompt: { itemId: string; expectedRevision: number } }, + routes: { + stop: () => Promise + interrupt: () => Promise + } +): Promise { + const validated = validatePendingPrompt(ctx, input.prompt) + if (!validated.ok) { + return validated + } + const route = ctx.adapter.routePromptCancel?.({ + sessionId: ctx.sessionId, + prompt: validated.prompt + }) + if (!route) { + return routes.interrupt() + } + const cancelled: CancelOutcome = { + ok: true, + value: { ...(input.turnId ? { turnId: input.turnId } : {}), cancelled: true } + } + if (route.kind === 'dismiss') { + const dismissed = await dismissPrompt(ctx, validated, true) + return dismissed.ok ? cancelled : dismissed + } + // Judged once the provider's accepted lifecycle has landed: its own cancel of the card, or the end + // of the turn that raised it, may still be queued. A failed drain judges what has landed. + await ctx.flushStreamedEvents().catch(() => undefined) + const current = validatePendingPrompt(ctx, input.prompt) + if (!current.ok) { + return current + } + if (!raisedByLiveTurn(ctx, current)) { + // A request that outlived its turn, such as a background agent's: the turn running now is not + // the one the user is cancelling, so nothing stops and the request is declined. + const dismissed = await dismissPrompt(ctx, current, true) + return dismissed.ok ? cancelled : dismissed + } + const stopped = await routes.stop() + if (!stopped.outcome.ok) { + return stopped.outcome + } + // Settled in the Stop's own step, so the card is not answerable while the child ends. That end + // takes the provider's request with it; a Stop that ends nothing must answer the request itself. + const dismissed = await dismissPrompt(ctx, current, !stopped.endsSession) + return dismissed.ok ? cancelled : dismissed +} + +function raisedByLiveTurn(ctx: AgentSessionTurnContext, pending: PendingPrompt): boolean { + const live = ctx.journal.liveTurnScope() + const raised = pending.item.turnScope + return live.kind === 'turn' && raised?.kind === 'turn' && raised.turnItemId === live.turnItemId +} + +/** Records the card as cancelled by the caller, the provider's lifecycle drained first so nothing + * it already sent lands after; `answer` also declines the provider's request. */ +async function dismissPrompt( + ctx: AgentSessionTurnContext, + pending: PendingPrompt, + answer: boolean +): Promise> { + const { item, prompt } = pending + const identity = parseAgentJournalItemKey(item.itemId) + if (!identity) { + return { + ok: false, + refusal: refuse( + 'agent_session_operation_invalid', + { reason: 'requestMalformed' }, + `Item id ${item.itemId} is not a well-formed item key.` + ) + } + } + let committed = false + const commit = async (): Promise => { + await ctx.flushStreamedEvents() + await ctx.journal.appendItem( + identity, + { + ...prompt, + resolution: { + state: 'cancelled', + selectedOptionId: null, + resolvedBy: ctx.resolvedBy, + resolvedAt: ctx.now() + } + }, + // A revision: the prompt keeps the turn it was raised in. + { fence: ctx.fence, turnScope: ctx.journal.liveTurnScope() } + ) + committed = true + } + try { + await ctx.adapter.dismissPrompt?.({ + sessionId: ctx.sessionId, + itemId: item.itemId, + fence: ctx.fence, + answer, + commit + }) + } catch (error) { + if (!committed && !(error instanceof AgentSessionPromptUnavailableError)) { + throw error + } + if (committed) { + // The adapter's error is Orca's; the row says only what the user needs to know. + await ctx.journal.appendItem( + { provider: 'orca', clientMessageId: `${item.itemId}#delivery` }, + { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('answerUnconfirmed'), { + surface: 'row' + }) + }, + { fence: ctx.fence, turnScope: ctx.journal.liveTurnScope() } + ) + } + } + // A request the provider already let go of, by its own cancel or the child's end, is still the + // user's to have cancelled. + if (!committed) { + await commit() + } + return { ok: true, value: null } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 75061435a1fe..230709e5d213 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -28,6 +28,7 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-a import { ensureStructuredAgentSessionAgent } from './structured-agent-session-agent-start' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { HOST_TEST_LOCATION, HOST_TEST_NOW as NOW, @@ -41,6 +42,9 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } +const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { + surface: 'rejection' +}) let root: string let store: AgentSessionRecordStore @@ -119,7 +123,7 @@ beforeEach(async () => { store = await openTestAgentSessionRecordStore(root) startHost() expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) - await host.close(SESSION) + await host.close(SESSION, 'evict') }) afterEach(async () => { @@ -642,7 +646,7 @@ describe('a quit with a message still queued', () => { starting.resolve() await quit - expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict') expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released' }) expect(dispatch).not.toHaveBeenCalled() expect(await afterRelaunch(id)).toMatchObject({ @@ -715,6 +719,98 @@ describe('how a stopped child ends the start its loop was waiting on', () => { expect(await statusRows()).toEqual([]) }) + /** The close's stop alone: a close that aborts after it leaves the conversation indexed. */ + function closeStopOnly() { + return host['serialize'](SESSION, () => + stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { + cause: 'user-close' + }) + ) + } + + /** A message queued behind a starting child when the close's stop cut it. `beforeStart` runs + * after the stop and before the loop looks again. */ + async function closedWhileStarting(beforeStart: () => void = () => undefined) { + const start = deferred() + adapterExtras = { + awaitStarted: vi.fn(() => start.promise), + closeSession: vi.fn(async () => true) + } + await restartHost() + acquire.mockImplementationOnce(spawnStartingChild) + const first = await accept('first') + await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalledTimes(1)) + await closeStopOnly() + const starts = acquire.mock.calls.length + beforeStart() + start.resolve() + await settleLoop() + return { first, starts } + } + + it('closes what was queued when the user closed the chat, and starts no child for it', async () => { + const { first, starts } = await closedWhileStarting() + + expect(await submission(first)).toMatchObject({ + dispatchState: 'rejected', + ...CHAT_CLOSED + }) + expect(acquire).toHaveBeenCalledTimes(starts) + expect(dispatch).not.toHaveBeenCalled() + expect(await statusRows()).toEqual([]) + }) + + it('starts no child when closing what was queued fails, and closes it on the next wake', async () => { + const { first, starts } = await closedWhileStarting(() => { + const journal = conversation()!.journal + const reject = journal.rejectQueuedSubmissions.bind(journal) + vi.spyOn(journal, 'rejectQueuedSubmissions').mockImplementation(async (...args) => { + if (args[1].rejection.kind === 'chatClosed') { + vi.mocked(journal.rejectQueuedSubmissions).mockImplementation(reject) + throw new Error('disk full') + } + return reject(...args) + }) + }) + + expect((await submission(first))?.dispatchState).toBe('pending') + expect(acquire).toHaveBeenCalledTimes(starts) + expect(dispatch).not.toHaveBeenCalled() + + const second = await accept('second') + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) + expect(await submission(first)).toMatchObject({ + dispatchState: 'rejected', + ...CHAT_CLOSED + }) + expect(dispatch).toHaveBeenCalledTimes(1) + }) + + it('goes on with a message sent in a later epoch, whose sequence restarts at or below the close', async () => { + await closedWhileStarting() + const session = conversation()! + await session.journal.rollEpoch( + 'corruption', + structuredAgentSessionConversationFence(store, SESSION) + ) + + const second = await accept('second') + + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) + expect(session.lastEndedChild).toMatchObject({ cause: 'user-close' }) + expect((await submission(second))!.acceptedSequence).toBeLessThanOrEqual( + session.lastEndedChild!.endedAt.sequence + ) + }) + + it('goes on with a message sent after the close, never failing it', async () => { + const second = await stoppedWhileStarting(closeStopOnly) + + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) + expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'user-close' }) + expect(await statusRows()).toEqual([]) + }) + it("fails the start after a host stop, as a start Orca stopped rather than the provider's (R2)", async () => { const reason = 'the start watchdog fired' const second = await stoppedWhileStarting(() => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-exit-proof.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-exit-proof.ts new file mode 100644 index 000000000000..9d27e570db4a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-exit-proof.ts @@ -0,0 +1,67 @@ +// What a cleanup or a stop proved about a provider child's exit, read from the adapter's typed +// verdicts: a failed acquisition rethrows with the verdict its cleanup reached, and a stop answers +// whether the provider root is gone. + +import { isAgentSessionWireRefusalCode } from '../../../shared/agent-session-wire-refusals' +import { + AgentSessionAcquisitionExitProvenError, + AgentSessionAcquisitionExitUnprovenError, + AgentSessionAcquisitionRefusal, + AgentSessionAcquisitionRootExitObservedError, + isAgentSessionPreSpawnError, + type StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' + +export async function rethrowAfterAgentSessionAcquisitionCleanup( + adapter: Pick, + sessionId: string, + cause: unknown +): Promise { + let released: boolean + try { + released = (await adapter.releaseAcquisition?.({ sessionId })) === true + } catch (cleanupError) { + // A root exit the cleanup observed first-hand keeps its classification and its + // provider diagnostic; the failure that triggered cleanup rides along as cause. + throw cleanupError instanceof AgentSessionAcquisitionRootExitObservedError + ? new AgentSessionAcquisitionRootExitObservedError( + new AggregateError([cause, cleanupError], cleanupError.message) + ) + : new AgentSessionAcquisitionExitUnprovenError( + new AggregateError([cause, cleanupError], 'agent session acquisition cleanup failed') + ) + } + if (released) { + throw provenExitAcquisitionFailure(cause) + } + throw new AgentSessionAcquisitionExitUnprovenError(cause) +} + +/** A failure whose child cleanup proved gone. One that already names its own verdict — a + * refusal, a typed exit proof, or a host store code — keeps it. */ +function provenExitAcquisitionFailure(cause: unknown): unknown { + const classified = + cause instanceof AgentSessionAcquisitionRefusal || + cause instanceof AgentSessionAcquisitionRootExitObservedError || + cause instanceof AgentSessionAcquisitionExitUnprovenError || + isAgentSessionPreSpawnError(cause) || + (cause instanceof Error && isAgentSessionWireRefusalCode(cause.message)) + return classified ? cause : new AgentSessionAcquisitionExitProvenError(cause) +} + +/** Whether a stop left the provider root gone. The lease follows the root, so a first-hand root + * exit or a processless child ends the session whatever its descendants did; any other + * failure still throws. */ +export async function stopAgentSessionProviderRoot(stop: () => Promise): Promise { + try { + return (await stop()) === true + } catch (error) { + if ( + error instanceof AgentSessionAcquisitionRootExitObservedError || + isAgentSessionPreSpawnError(error) + ) { + return true + } + throw error + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index ff4b5e9c4ee8..f5e0e14b09fb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts @@ -140,7 +140,7 @@ describe('a publish-first Claude create whose init is slow', () => { await host.attach(CALLER, { ...params, options: { model: 'opus' } }) await adapter.awaitStarted(SESSION) await Promise.all(lifecycle) - await host.close(SESSION) + await host.close(SESSION, 'evict') const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 // Starting the chat again resumes the session under a new fence. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts index 9cc22f9547a0..2be4a8ea12b6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts @@ -98,19 +98,22 @@ describe('a /compact in flight', () => { describe('/clear', () => { it('in flight, refuses a capable send as today: no card lands on the source it supersedes', async () => { - const attach = rig.host.attach.bind(rig.host) + const commit = rig.store.commitConversationClear let release: (() => void) | undefined const released = new Promise((resolve) => { release = resolve }) - const spy = vi.spyOn(rig.host, 'attach').mockImplementationOnce(async (...args) => { - await released - return attach(...args) - }) + const committing = vi.fn() + const spy = vi + .spyOn(rig.store, 'commitConversationClear') + .mockImplementationOnce(async (clear) => { + committing() + await released + return commit(clear) + }) try { const cleared = command('clear') - // Nothing is recorded before the clear commits; wait until it is starting the replacement. - await eventually(() => expect(spy).toHaveBeenCalled()) + await eventually(() => expect(committing).toHaveBeenCalledOnce()) expect(await rig.send('sent while clearing', 'queue-if-active').result).toEqual(WAIT_REFUSAL) release?.() const done = await cleared @@ -125,6 +128,58 @@ describe('/clear', () => { } }) + it('carries drafts to a replacement no agent has started, before it answers; the first send starts the agent ahead of them', async () => { + const working = await rig.workingSend() + const firstId = await queuedId(rig.send('first draft', 'queue-if-active').result) + const secondId = await queuedId(rig.send('second draft', 'queue-if-active').result) + await rig.stop() + await rig.settleAccepted(working, 'a') + const cleared = await command('clear') + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error('expected a replacement session') + } + // The clear started nothing, yet the drafts had already moved when it answered. + expect(rig.store.getRecord(replacementId)).toMatchObject({ + providerHandleChain: [], + lease: { claimStatus: 'released' } + }) + expect(rig.host.collaboratorsForTests().sessions.get(replacementId)?.child ?? null).toBeNull() + expect(await rig.drafts()).toHaveLength(0) + expect(await rig.drafts(replacementId)).toEqual([ + { messageId: firstId, state: 'waiting' }, + { messageId: secondId, state: 'waiting' } + ]) + expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + + const body = hostTestMessage('first in the new chat') + const fields = { body, delivery: 'queue-if-active' as const } + const sentId = hostTestOperationId() + expect( + await rig.host.send(CALLER, { + envelope: rig.envelope(fields, 'agentSession.send', sentId, replacementId), + ...fields, + userSend: true + }) + ).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + await eventually(() => + expect(rig.store.getRecord(replacementId)?.providerHandleChain).toHaveLength(1) + ) + await rig.host.settleLateDispatch({ + sessionId: replacementId, + clientMessageId: sentId, + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-first', ordinal: 0 } + }) + await eventually(async () => + expect( + (await rig.host.journalSnapshot(replacementId)).submissions.map((entry) => + entry.clientMessageId === sentId ? 'sent' : entry.queuedMessageId + ) + ).toEqual(['sent', firstId]) + ) + expect(await rig.queuePause(replacementId)).toBeNull() + }) + it("a carried draft sent on the replacement: the provider's echo folds into its one bubble", async () => { const working = await rig.workingSend() const draftId = await queuedId(rig.send('carried text', 'queue-if-active').result) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts index bc4697be6df8..26eaa8b8ab59 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts @@ -286,9 +286,10 @@ describe('Send-now rerun', () => { expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) ) // The host died before the Send's answer settled: its ledger row is still pending, so it reruns. - for (const row of store['transactions'].state.operations.values()) { + const operations = store['transactions'].state.operations + for (const [key, row] of operations) { if (row.operationId === operationId) { - row.outcome = { status: 'pending' } + operations.set(key, { ...row, outcome: { status: 'pending' } }) } } const count = (await host.journalSnapshot(SESSION)).submissions.length diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index 9aed0d4acb84..d63af3ba7d20 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -245,7 +245,7 @@ export async function createQueuedMessageTestRig() { /** A host-process restart, as the queue sees it: the conversation closes, and * opens afresh under a new instance id while its rows survive. */ async function restartHostProcess(): Promise { - await host.close(SESSION) + await host.close(SESSION, 'evict') rotateStructuredAgentSessionHostInstanceForTests() } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts index aa9d31cbf105..33de806ff3b7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -270,7 +270,7 @@ describe('held drafts', () => { // The queue is paused, not the card: it carries no hold of its own. expect(await drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) - await host.close(SESSION) + await host.close(SESSION, 'evict') expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) await new Promise((resolve) => setTimeout(resolve, 250)) expect(await rig.handoff(draftId)).toBeUndefined() @@ -418,7 +418,7 @@ describe('Stop and Delete', () => { await settleAccepted(working, 'a') // Evict the handle and reopen (the history read opens the conversation at // rest): the pause is derived from what the journal holds, so nothing drains. - await host.close(SESSION) + await host.close(SESSION, 'evict') expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) await new Promise((resolve) => setTimeout(resolve, 250)) expect(await rig.handoff(draftId)).toBeUndefined() @@ -728,6 +728,8 @@ describe('/clear', () => { if (!replacementId) { throw new Error('expected a replacement session') } + // Nothing to carry, so nothing opened the new conversation. + expect(host.hasSession(replacementId)).toBe(false) expect(await drafts(replacementId)).toHaveLength(0) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index 9da974a44a43..5b7f4bdf16c0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -34,7 +34,7 @@ import { unsettledQueuedMessages } from './structured-agent-session-queued-stop' import { mutateStructuredAgentSession, type StructuredAgentSessionMutationContext -} from './structured-agent-session-host-mutations' +} from './structured-agent-session-mutation-context' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import { openForWrite, @@ -82,19 +82,19 @@ export async function withdrawQueuedMessagesForOperation( * discarded, so they wait for the user's next turn there, or Resume, rather than * sending into the fresh context unasked. Each card lands with the pause in one * transaction, so the drain never sees a carried card unpaused and no pause is - * left over an empty queue if an insert fails. Runs after the - * replacement's attach succeeded and before the clear commits. Each insert is - * idempotent on (session, message), so a retried clear replays it safely; the source rows are then tombstoned. Bookkeeping around the clear: - * a failure leaves the cards on the superseded source — whose supersession - * fence already blocks the drain — reported, never gating the clear. A crash - * between the copy and the tombstone leaves both, which the fence also makes - * harmless: nothing is lost and nothing runs. + * left over an empty queue if an insert fails. Runs after the clear commits, + * opening the replacement's conversation only when there are drafts to carry; + * the source rows are then tombstoned. Bookkeeping around the clear: a failure, + * or a crash before the carry, leaves the cards on the superseded source — whose + * supersession fence already blocks the drain — reported, never gating the + * clear. A crash between the copy and the tombstone leaves both, which the + * fence also makes harmless: nothing is lost and nothing runs. */ export async function carryQueuedMessagesToClearReplacement( ctx: AgentSessionTurnContext, input: { replacementSessionId: string - replacementJournal: AgentSessionJournal | undefined + openReplacementJournal: () => Promise callerKey: string operationId: string } @@ -104,7 +104,7 @@ export async function carryQueuedMessagesToClearReplacement( if (rows.length === 0) { return } - const replacement = input.replacementJournal + const replacement = await input.openReplacementJournal() if (!replacement) { throw new Error('the replacement journal is not open') } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts index 2c5332db1bb3..2dd2f567b879 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts @@ -70,7 +70,7 @@ async function twoCardsBehindWork() { describe("a newer Orca's journal", () => { it('shows the cards, and refuses a queued send, Send-now and Delete with the update words', async () => { const { first, second, cards } = await twoCardsBehindWork() - await reopenOnNewerOrcaDatabase(() => rig.host.close(HOST_TEST_SESSION)) + await reopenOnNewerOrcaDatabase(() => rig.host.close(HOST_TEST_SESSION, 'evict')) expect(await readOnlyQueue()).toEqual({ cards, pause: null }) // The waiting cards would queue this send behind them; the journal takes no new draft. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index 1349a6a178e0..63a12313bf68 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -342,7 +342,7 @@ describe("a restart's pause", () => { await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) // Reopened, that turn is "before this open", yet the pause it ended stays ended: // the lift adopted the rows into this process. - await rig.host.close(HOST_TEST_SESSION) + await rig.host.close(HOST_TEST_SESSION, 'evict') expect(await rig.queuePause()).toBeNull() expect(await rig.drafts()).toContainEqual({ messageId: second, state: 'waiting' }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts index 5eaedfffb384..09cab618ab9a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts @@ -39,8 +39,8 @@ describe('StructuredAgentSessionReadableRestorer', () => { adapter: {} }, supportsRecord: () => true, - reconcile: async () => null, - resolveRecovery: async () => undefined, + reconcile: async () => true, + resolveRecovery: async () => true, serialize: async (_sessionId, task) => task(), hasSession: () => false, onReadable: () => undefined diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index f7d26c154f1f..538fb0b9dbc6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -1,8 +1,9 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A turn that was running when its host went away ends when recovery settles it. That settlement is // the edge the user needs to see — their work stopped — so the session reads as newly done then, -// and nothing along the way may call it a success. Every hop is the real one: durable journal, -// recovery settlement, status feed, the host's status row, and the turn-completion feed. +// with what the host observed of the end as its verdict, and nothing along the way may call it a +// success. Every hop is the real one: durable journal, recovery settlement, status feed, the host's +// status row, and the turn-completion feed. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -12,6 +13,12 @@ import type { AgentSessionStatusSummary, AgentSessionTurnCompletionEvent } from '../../../shared/agent-session-wire' +import { + agentTurnEndedOnPurpose, + agentVerdictDisplayMark +} from '../../../shared/agent-main-agent-verdict' +import { formatNativeChatTurnStatusLabel } from '../../../shared/native-chat-turn-status' +import { selectStructuredAgentSettledTurns } from '../../../shared/structured-agent-session-turn-timing' import { AgentHookServer, _internals } from '../../agent-hooks/server' import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -19,7 +26,11 @@ import { settleStaleStructuredAgentSessionState, settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' -import type { StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict' +import { + childEndCauseOfEndedEvent, + turnVerdictForChildEnd, + type StructuredAgentSessionTurnVerdict +} from './structured-agent-session-stale-turn-verdict' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' import { StructuredAgentSessionTurnCompletionFeed } from './structured-agent-session-turn-completion-feed' @@ -131,11 +142,22 @@ function settleDeadGeneration( describe('a turn recovery settled after its host went away', () => { it.each([ - ['an unverifiable end', { state: 'unverifiable' } as const], - ['an exit observed before the restart', { state: 'interrupted', completedAt: EXIT_OBSERVED }] - ] satisfies [string, StructuredAgentSessionTurnVerdict][])( - 'is done as of the recovery, never as a success: %s', - async (_label, verdict) => { + ['an unverifiable end', { state: 'unverifiable' } as const, 'unconfirmed', 'unconfirmed'], + [ + 'an exit observed before the restart', + { state: 'interrupted', completedAt: EXIT_OBSERVED }, + 'interruption', + // A turn the user did not stop is a fault, marked as a failure is. + 'failed' + ] + ] satisfies [ + string, + StructuredAgentSessionTurnVerdict, + 'unconfirmed' | 'interruption', + 'unconfirmed' | 'failed' + ][])( + 'is done as of the recovery with the end the host observed, never a success: %s', + async (_label, verdict, outcome, mark) => { const session = await sessionWithRunningTurn() session.recoverAt(RECOVERED) expect(await settleDeadGeneration(session.journal, verdict)).toBe(true) @@ -143,22 +165,75 @@ describe('a turn recovery settled after its host went away', () => { expect(session.summaries.at(-1)).toMatchObject({ status: 'idle', - statusStartedAt: RECOVERED + statusStartedAt: RECOVERED, + turnOutcome: outcome }) - expect(session.summaries.at(-1)).not.toHaveProperty('turnOutcome') const [row] = session.server.getStatusSnapshot() // A done row dated at the recovery is a completion the user has not read yet. expect(row).toMatchObject({ state: 'done', stateStartedAt: RECOVERED, - mainAgent: { state: 'done', stateStartedAt: RECOVERED } + mainAgent: { state: 'done', outcome, stateStartedAt: RECOVERED } }) - expect(row?.mainAgent).not.toHaveProperty('outcome') + // Nobody stopped it: the flag older readers take as a user's stop stays down. + expect(row?.interrupted ?? false).toBe(false) + // The sidebar and tab read the published row, with no user action in between. + expect(row && agentVerdictDisplayMark(row)).toBe(mark) + expect(row && agentTurnEndedOnPurpose(row)).toBe(false) // The dot and the OS notification come only from a completion event, and none is sent. expect(session.completionEvents).toEqual([]) } ) + // The chat's turn bar and the tab's mark read one verdict: a turn nobody stopped failed, and + // must never show the done tick of a finished turn. + it.each([ + [ + 'a restart', + (journal: AgentSessionJournal) => + settleDeadGeneration(journal, { state: 'interrupted', completedAt: EXIT_OBSERVED }) + ], + [ + 'quitting Orca', + // A quit evicts the child, and its adapter settles the open turn through the one mapping. + (journal: AgentSessionJournal) => + journal.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 9 }, + { + kind: 'turn', + turnId: 'turn-1', + startedAt: TURN_STARTED, + ...turnVerdictForChildEnd( + childEndCauseOfEndedEvent({ + type: 'ended', + cause: 'requested-close', + stopCause: 'evict' + }), + EXIT_OBSERVED + ) + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + ] + ] as const)( + 'reads Failed after N, marked failed, for a turn cut off by %s', + async (_label, cut) => { + const session = await sessionWithRunningTurn() + session.recoverAt(RECOVERED) + await cut(session.journal) + session.publish() + + const [row] = session.server.getStatusSnapshot() + expect(row && agentVerdictDisplayMark(row)).toBe('failed') + const [settled] = [ + ...selectStructuredAgentSettledTurns(session.journal.snapshot().items).values() + ] + expect(settled && formatNativeChatTurnStatusLabel({ elapsedSeconds: 0, ...settled })).toBe( + 'Failed after 1s' + ) + } + ) + it('is dated the same way when a new provider child finds the turn still running', async () => { const session = await sessionWithRunningTurn() session.recoverAt(RECOVERED) @@ -173,12 +248,17 @@ describe('a turn recovery settled after its host went away', () => { expect(session.summaries.at(-1)).toMatchObject({ status: 'idle', - statusStartedAt: RECOVERED + statusStartedAt: RECOVERED, + // No evidence of the old owner's death: the end cannot be proven. + turnOutcome: 'unconfirmed' }) - expect(session.server.getStatusSnapshot()[0]).toMatchObject({ + const [row] = session.server.getStatusSnapshot() + expect(row).toMatchObject({ state: 'done', - stateStartedAt: RECOVERED + stateStartedAt: RECOVERED, + mainAgent: { state: 'done', outcome: 'unconfirmed' } }) + expect(row && agentVerdictDisplayMark(row)).toBe('unconfirmed') expect(session.completionEvents).toEqual([]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index 09ba97ee8e31..83082bf923a5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -346,7 +346,7 @@ describe('agentSessionRefusalOperationState host oracle', () => { } const unreadable = await createHarness() - await unreadable.host.close(SESSION) + await unreadable.host.close(SESSION, 'evict') unreadable.host.deps.adapter.historyFilePath = async () => { throw new Error('transcript unreadable') } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index 0c890767ac22..e441a156b494 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -13,6 +13,7 @@ import type { AgentSessionStatusEvent, AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { @@ -44,7 +45,7 @@ export type RestTestAdapter = { acknowledgeSessionRelease: Mock< NonNullable > - backgroundTaskState: Mock> + holdsDispatch: Mock> readOptions: Mock> } @@ -55,7 +56,12 @@ export type RestTestRig = { adapter: RestTestAdapter clock: { now: number } statusEvents: AgentSessionStatusEvent[] - sink: { publish: Mock; forget: Mock } + /** `readChildWork` serves the session's child records, as the host's store does. */ + sink: { + publish: Mock + forget: Mock + readChildWork: Mock<(subject: unknown) => AgentChildWorkView[]> + } /** Opens a fresh host over the same store and journals: what a restart leaves behind. */ restart: (deps?: Partial) => Promise dispose: () => Promise @@ -126,7 +132,11 @@ export async function createRestTestRig( const root = await mkdtemp(join(tmpdir(), 'orca-rest-')) const clock = { now: HOST_TEST_NOW } const statusEvents: AgentSessionStatusEvent[] = [] - const sink = { publish: vi.fn(), forget: vi.fn() } + const sink: RestTestRig['sink'] = { + publish: vi.fn(), + forget: vi.fn(), + readChildWork: vi.fn((): AgentChildWorkView[] => []) + } let store = await openTestAgentSessionRecordStore(root) const adapter: RestTestAdapter = { acquire: vi.fn(async ({ fence, spawnToken }) => ({ @@ -145,7 +155,7 @@ export async function createRestTestRig( closeSession: vi.fn(async () => true), dispatch: vi.fn(async () => acceptedDispatch()), acknowledgeSessionRelease: vi.fn(), - backgroundTaskState: vi.fn(() => undefined), + holdsDispatch: vi.fn(() => false), readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } })) } const hostFor = (overrides: Partial) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index 3bf122afde3a..9bb2173f42fc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -12,16 +12,19 @@ import { import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { parseAgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { StructuredAgentSessionHost } from './structured-agent-session-host' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' +import { childRecord } from './structured-agent-session-restart-resume-test-harness' import { adapter, attach, CALLER, envelope, hostTestState, - replaceHostTestState + replaceHostTestState, + serveHostTestChildWork } from './structured-agent-session-host-test-harness' import { HOST_TEST_NOW as NOW, @@ -54,6 +57,10 @@ export async function interruptedRestart( }) ) { const previous = hostTestState() + let children: AgentChildWorkView[] = [] + if (work === 'children') { + serveHostTestChildWork(() => children) + } await attach() const events = previous.acquire.mock.calls[0]?.[0].events if (!events) { @@ -98,10 +105,7 @@ export async function interruptedRestart( ] }) events.appendItem(group, roster('working'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - previous.host.deps.adapter.backgroundTaskState = () => ({ - state: 'monitoring', - tasks: [{ id: 'child-1', kind: 'agent', description: 'Review loop 4', state: 'working' }] - }) + children = [childRecord({ id: 'child-1', kind: 'agent', description: 'Review loop 4' })] // As the real adapters do: the child's own close settles the children it can no longer hear. previous.host.deps.adapter.closeSession = async () => { events.appendItem(group, roster('unverifiable'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts index 3b6cc30271e5..41dd7913d85a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts @@ -11,7 +11,7 @@ import { classifyStoreFailure } from './structured-agent-session-attach' const MAX_RECONCILIATION_PASSES = 8 -/** Adjudicates leases loaded by this process or refreshed from another writer. +/** Adjudicates leases loaded by this process. * Answers with the refusal attach owes its caller, or null once settled. */ export function createRestartReconciler(deps: { store: AgentSessionRecordStore @@ -45,6 +45,76 @@ export function createRestartReconciler(deps: { } } +/** Reports each distinct failure once, until `clear` says the bookkeeping settled again: the + * startup check, the restore pass and a restore retried after a failed journal open would each + * log the same store failure. */ +export type ReaderBookkeepingFailures = { + report: (failure: unknown) => void + clear: () => void +} + +export function reportEachFailureOnce( + onFailure: ((failure: unknown) => void) | undefined +): ReaderBookkeepingFailures { + let reported: string | null = null + return { + report: (failure) => { + const key = failureKey(failure) + if (key !== reported) { + reported = key + try { + onFailure?.(failure) + } catch (sinkError) { + // A throwing sink must not turn the reported failure back into a failed read. + console.warn('[structured-agent-session] reporting a lease bookkeeping failure failed', { + failure, + sinkError + }) + } + } + }, + clear: () => { + reported = null + } + } +} + +/** The reconcile a reader runs, at startup and before each restored read: it never throws, since + * an unreconciled lease grants no writer and the next send reconciles again before it acts. + * Answers whether every lease is settled. */ +export function createReaderReconcile( + reconcile: (sessionId: string) => Promise, + failures: ReaderBookkeepingFailures +): (sessionId: string) => Promise { + return async (sessionId) => { + let failure: unknown + try { + const refusal = await reconcile(sessionId) + if (!refusal) { + failures.clear() + return true + } + failure = refusal + } catch (error) { + failure = error + } + failures.report(failure) + return false + } +} + +function failureKey(failure: unknown): string { + if (typeof failure === 'object' && failure !== null) { + if ('code' in failure && failure.code) { + return String(failure.code) + } + if ('message' in failure) { + return String(failure.message) + } + } + return String(failure) +} + async function reconcileCurrentLeases(deps: { store: AgentSessionRecordStore probe: (record: AgentSessionRecord) => Promise diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts index c7d04ea64cc4..4da648576ed6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts @@ -62,8 +62,8 @@ describe('restart journal restoration', () => { const restoration = restoreStructuredAgentSessionsOnRestart({ openDeps: NO_OPEN_DEPS, records, - reconcile: async () => null, - resolveRecovery: async () => undefined, + reconcile: async () => true, + resolveRecovery: async () => true, serialize: async (_sessionId, task) => task(), hasSession: () => false, onReadable: () => undefined @@ -103,8 +103,8 @@ describe('restart journal restoration', () => { await restoreStructuredAgentSessionsOnRestart({ openDeps: NO_OPEN_DEPS, records, - reconcile: async () => null, - resolveRecovery: async () => undefined, + reconcile: async () => true, + resolveRecovery: async () => true, serialize: async (_sessionId, task) => task(), hasSession: () => false, onReadable: () => undefined @@ -150,9 +150,10 @@ describe('restart journal restoration', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here. records: [{ sessionId: 'session-1' } as AgentSessionRecord], openDeps: NO_OPEN_DEPS, - reconcile: async () => null, + reconcile: async () => true, resolveRecovery: async () => { calls.push('resolveRecovery') + return true }, serialize: async (_sessionId, task) => task(), hasSession: () => false, @@ -164,6 +165,58 @@ describe('restart journal restoration', () => { expect(calls).toEqual(['resolveRecovery', 'open', 'onReadable:restored']) }) + // Each failed bookkeeping call stands for one refused store write. + describe('once lease bookkeeping fails in a pass', () => { + const records = Array.from( + { length: 8 }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here. + (_, index) => ({ sessionId: `session-${index}` }) as AgentSessionRecord + ) + const restore = ( + bookkeeping: Pick< + Parameters[0], + 'reconcile' | 'resolveRecovery' + > + ) => + restoreStructuredAgentSessionsOnRestart({ + openDeps: NO_OPEN_DEPS, + records, + ...bookkeeping, + serialize: async (_sessionId, task) => task(), + hasSession: () => false, + onReadable: () => undefined + }) + + /** A failure that takes a while, as a lock wait does, so the chats open at once overlap it. */ + const slowFailure = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 5)) + return false + } + + beforeEach(() => restoreRead.mockResolvedValue(null)) + + it('skips it for every chat when the pass check fails, and still opens them all', async () => { + const reconcile = vi.fn(slowFailure) + const resolveRecovery = vi.fn(async () => true) + + await restore({ reconcile, resolveRecovery }) + + expect(reconcile).toHaveBeenCalledOnce() + expect(resolveRecovery).not.toHaveBeenCalled() + expect(restoreRead).toHaveBeenCalledTimes(records.length) + }) + + it('starts no more after the first failed recovery, and still opens every chat', async () => { + const resolveRecovery = vi.fn(slowFailure) + + await restore({ reconcile: async () => true, resolveRecovery }) + + // Only those already started when the first failed: at most one per chat open at once. + expect(resolveRecovery.mock.calls.length).toBeLessThanOrEqual(4) + expect(restoreRead).toHaveBeenCalledTimes(records.length) + }) + }) + it('does not settle again when a second restore finds the session already open', async () => { restoreRead.mockResolvedValue({ session: { journal: {}, params: {}, child: null }, @@ -174,8 +227,8 @@ describe('restart journal restoration', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here. records: [{ sessionId: 'session-1' } as AgentSessionRecord], openDeps: NO_OPEN_DEPS, - reconcile: async () => null, - resolveRecovery: async () => undefined, + reconcile: async () => true, + resolveRecovery: async () => true, serialize: async (_sessionId, task) => task(), hasSession: () => true, onReadable: () => undefined diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts index 4e4b7f1e5ddd..29ea17845d8a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -13,7 +13,6 @@ import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { mapWithConcurrency } from '../../../shared/map-with-concurrency' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { @@ -28,8 +27,11 @@ export type StructuredAgentSessionReadRestoreDeps = { openDeps: StructuredAgentSessionConversationOpenDeps & { store: Pick } - reconcile: (sessionId: string) => Promise - resolveRecovery: (sessionId: string) => Promise + // Lease bookkeeping. Neither throws: a read grants no writer, so bookkeeping must not block it. + /** Whether every lease is settled. */ + reconcile: (sessionId: string) => Promise + /** False when its store write failed; the next attach or send resolves it again. */ + resolveRecovery: (sessionId: string) => Promise serialize: (sessionId: string, task: () => Promise) => Promise hasSession: (sessionId: string) => boolean onReadable: ( @@ -41,13 +43,10 @@ export type StructuredAgentSessionReadRestoreDeps = { /** One session's share of the restart restore. Startup maps this over every supported record. */ async function restoreOneStructuredAgentSessionRead( input: StructuredAgentSessionReadRestoreDeps, - sessionId: string + sessionId: string, + settleLeases: (sessionId: string) => Promise ): Promise { - const unreconciled = await input.reconcile(sessionId) - if (!unreconciled) { - // A session latched in recovery exits here at startup, without waiting for a client. - await input.resolveRecovery(sessionId) - } + await settleLeases(sessionId) await input.serialize(sessionId, () => restoreOneStructuredAgentSessionReadUnderSerialize(input, sessionId) ) @@ -73,9 +72,26 @@ async function restoreOneStructuredAgentSessionReadUnderSerialize( export async function restoreStructuredAgentSessionsOnRestart( input: StructuredAgentSessionReadRestoreDeps & { records: AgentSessionRecord[] } ): Promise { + const [first] = input.records + if (!first) { + return + } + // One check for the pass. Each chat checks again while it holds, since another writer can mark + // leases unreconciled mid-pass; after the first failure, retrying per chat only waits on the + // same store again, and the next attach or send settles those chats instead. + let settled = await input.reconcile(first.sessionId) + const settleLeases = async (sessionId: string): Promise => { + // A session latched in recovery exits here at startup, without waiting for a client. + if ( + settled && + !((await input.reconcile(sessionId)) && (await input.resolveRecovery(sessionId))) + ) { + settled = false + } + } await mapWithConcurrency(input.records, JOURNAL_RESTORE_CONCURRENCY, async ({ sessionId }) => { // A journal open is synchronous SQLite: without a macrotask per chat the restore is one long task. await yieldToEventLoop() - await restoreOneStructuredAgentSessionRead(input, sessionId) + await restoreOneStructuredAgentSessionRead(input, sessionId, settleLeases) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index d053caf006b2..af2a0970a6fc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -87,7 +87,7 @@ export function createStructuredAgentSessionRestartResume( const witnesses = createStructuredAgentSessionRestartWitnesses({ sessions, getRecord: deps.store.getRecord, - backgroundTasks: (sessionId) => deps.adapter.backgroundTaskState?.(sessionId)?.tasks, + childWork: surfaces.readChildWork, ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), teardownId: randomUUID(), now: surfaces.now, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts index e359e31157b9..93c33146743b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts @@ -11,6 +11,7 @@ import type { } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { structuredAgentSessionResumableSet } from './structured-agent-session-restart-resume-set' export const SESSION = 'session-working-1' @@ -195,6 +196,21 @@ export function submission( } } +/** A child record as the host's store serves it; live unless the test settles it. */ +export function childRecord( + child: Pick & Partial +): AgentChildWorkView { + return { + state: 'working', + membership: child.state === 'done' || child.state === 'idle' ? 'settled' : 'live', + firstObservedAt: NOW, + observedAt: NOW, + stoppable: false, + invocation: { invocationId: `spawn-${child.id}`, generation: 1 }, + ...child + } +} + /** The epoch the fake journal reports. */ export const EPOCH = 'epoch-1' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts index 4004502e4081..bb9eb500d1db 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts @@ -11,6 +11,7 @@ import type { AgentSessionMutationResult, AgentSessionSendResult } from '../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { MAX_TIMER_DELAY_MS } from '../../../shared/timer-delay' import type { SendSettlementWaitOptions } from './structured-agent-session-send-settlement' @@ -30,6 +31,8 @@ export type StructuredAgentSessionRestartResumeSurfaces = { clientMessageId: string ) => Promise<{ value: AgentSessionSendResult } | undefined> onNoteFailed: (sessionId: string, error: unknown) => void + /** The session's child records, the host's one read of them. */ + readChildWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined now: () => number } @@ -60,7 +63,7 @@ type RestartResumeHostBindings = { export function structuredAgentSessionRestartResumeSurfaces( host: RestartResumeHostBindings, now: () => number -): StructuredAgentSessionRestartResumeSurfaces { +): Omit { return { revealSession: host.revealSession, send: (params) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts index efcd70345a83..a2ed07db2916 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts @@ -15,6 +15,7 @@ import { } from './structured-agent-session-restart-resume-runner' import { structuredAgentSessionWorkingAtStop } from './structured-agent-session-working-at-teardown' import { + childRecord, CLAUDE_ROOT, claudeRecord, EPOCH, @@ -52,7 +53,7 @@ describe('deriving what was working at teardown', () => { [SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }] ]), getRecord: () => record(), - backgroundTasks: () => undefined, + childWork: () => undefined, trigger: 'quit', teardownId: TEARDOWN_CURRENT, now: NOW @@ -79,7 +80,7 @@ describe('deriving what was working at teardown', () => { [SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }] ]), getRecord: () => record(), - backgroundTasks: () => undefined, + childWork: () => undefined, trigger: 'update', teardownId: TEARDOWN_CURRENT, now: NOW @@ -93,7 +94,7 @@ describe('deriving what was working at teardown', () => { markersAtTeardown({ sessions: new Map([[SESSION, { journal: journal([]), child: { fence: 1 } }]]), getRecord: () => record(), - backgroundTasks: () => undefined, + childWork: () => undefined, trigger: 'quit', teardownId: TEARDOWN_CURRENT, now: NOW @@ -108,7 +109,7 @@ describe('deriving what was working at teardown', () => { [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] ]), getRecord: () => record(), - backgroundTasks: () => undefined, + childWork: () => undefined, trigger: 'quit', teardownId: TEARDOWN_CURRENT, now: NOW @@ -125,7 +126,7 @@ describe('deriving what was working at teardown', () => { [SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: null }] ]), getRecord: () => record(), - backgroundTasks: () => undefined, + childWork: () => undefined, trigger: 'quit', teardownId: TEARDOWN_CURRENT, now: NOW @@ -148,7 +149,7 @@ describe('deriving what was working at teardown', () => { ] ]), getRecord: () => record(), - backgroundTasks: () => undefined, + childWork: () => undefined, trigger: 'quit', teardownId: TEARDOWN_CURRENT, now: NOW @@ -170,9 +171,7 @@ describe('deriving what was working at teardown', () => { [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] ]), getRecord: () => record(), - backgroundTasks: () => [ - { id: 'task-a', kind: 'agent', description: 'Review loop 4', state: 'working' } - ], + childWork: () => [childRecord({ id: 'task-a', kind: 'agent', description: 'Review loop 4' })], trigger: 'update', teardownId: TEARDOWN_CURRENT, now: NOW @@ -193,14 +192,15 @@ describe('deriving what was working at teardown', () => { [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] ]), getRecord: () => record(), - backgroundTasks: () => [ - { id: 'gone', kind: 'agent', description: 'Finished agent', state: 'done' }, - ...Array.from({ length: 20 }, (_, index) => ({ - id: `live-${index}`, - kind: 'command' as const, - description: `Shell ${index}${'x'.repeat(300)}`, - state: 'working' as const - })) + childWork: () => [ + childRecord({ id: 'gone', kind: 'agent', description: 'Finished agent', state: 'done' }), + ...Array.from({ length: 20 }, (_, index) => + childRecord({ + id: `live-${index}`, + kind: 'command', + description: `Shell ${index}${'x'.repeat(300)}` + }) + ) ], trigger: 'quit', teardownId: TEARDOWN_CURRENT, @@ -218,7 +218,9 @@ describe('deriving what was working at teardown', () => { [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] ]), getRecord: () => record(), - backgroundTasks: () => [{ id: 'task-m', kind: 'monitor', name: 'ci-watch' }], + childWork: () => [ + childRecord({ id: 'task-m', kind: 'monitor', name: 'ci-watch', state: 'monitoring' }) + ], trigger: 'quit', teardownId: TEARDOWN_CURRENT, now: NOW @@ -235,9 +237,9 @@ describe('deriving what was working at teardown', () => { [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] ]), getRecord: () => record(), - backgroundTasks: () => [ - { id: 'task-a', kind: 'agent', state: 'done' }, - { id: 'task-b', kind: 'command', state: 'idle' } + childWork: () => [ + childRecord({ id: 'task-a', kind: 'agent', state: 'done' }), + childRecord({ id: 'task-b', kind: 'command', state: 'idle' }) ], trigger: 'quit', teardownId: TEARDOWN_CURRENT, @@ -253,7 +255,7 @@ describe('deriving what was working at teardown', () => { [SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }] ]), getRecord: () => claudeRecord(null), - backgroundTasks: () => undefined, + childWork: () => undefined, trigger: 'quit', teardownId: TEARDOWN_CURRENT, now: NOW @@ -269,7 +271,7 @@ describe('deriving what was working at teardown', () => { [SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }] ]), getRecord: () => record({ chain: [] }), - backgroundTasks: () => undefined, + childWork: () => undefined, trigger: 'quit', teardownId: TEARDOWN_CURRENT, now: NOW @@ -292,7 +294,7 @@ describe('deriving what was working at teardown', () => { ] ]), getRecord: () => claudeRecord(null), - backgroundTasks: () => undefined, + childWork: () => undefined, trigger: 'quit', teardownId: TEARDOWN_CURRENT, now: NOW @@ -317,7 +319,7 @@ describe('deriving what was working at teardown', () => { ] ]), getRecord: () => claudeRecord(null), - backgroundTasks: () => undefined, + childWork: () => undefined, trigger: 'quit', teardownId: TEARDOWN_CURRENT, now: NOW @@ -342,7 +344,7 @@ describe('deriving what was working at teardown', () => { ] ]), getRecord: () => claudeRecord(null), - backgroundTasks: () => undefined, + childWork: () => undefined, trigger: 'quit', teardownId: TEARDOWN_CURRENT, now: NOW @@ -375,7 +377,7 @@ describe('deriving what was working at teardown', () => { ] ]), getRecord: () => claudeRecord(null), - backgroundTasks: () => undefined, + childWork: () => undefined, trigger: 'quit', teardownId: TEARDOWN_CURRENT, now: NOW @@ -403,7 +405,7 @@ describe('deriving what was working at teardown', () => { ] ]), getRecord: () => record(), - backgroundTasks: () => undefined, + childWork: () => undefined, trigger: 'quit', teardownId: TEARDOWN_CURRENT, now: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts index 2ea9e6f57b77..d611bdb1589b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts @@ -1,8 +1,16 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { expect, it, vi } from 'vitest' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' -import { attach, hostTestState } from './structured-agent-session-host-test-harness' -import { pendingApproval } from './structured-agent-session-restart-resume-test-harness' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import { + attach, + hostTestState, + serveHostTestChildWork +} from './structured-agent-session-host-test-harness' +import { + childRecord, + pendingApproval +} from './structured-agent-session-restart-resume-test-harness' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -118,8 +126,10 @@ it.each(['approval', 'question', 'completed'] as const)( } ) -// The roster is read off the live adapter at teardown: eviction clears it moments later. +// The child records are read at teardown: eviction settles them moments later. it('marks a settled chat whose subagent was still running', async () => { + let children: AgentChildWorkView[] = [] + serveHostTestChildWork(() => children) await attach() const { host, root, acquire } = hostTestState() const events = acquire.mock.calls[0]?.[0].events @@ -132,15 +142,12 @@ it('marks a settled chat whose subagent was still running', async () => { { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushStreamedEvents(SESSION) - host.deps.adapter.backgroundTaskState = () => ({ - state: 'monitoring', - tasks: [{ id: 'task-a', kind: 'agent', description: 'Review loop 4', state: 'working' }] - }) + children = [childRecord({ id: 'task-a', kind: 'agent', description: 'Review loop 4' })] await host.flushAllStreamedEvents() const [offered] = await new AgentSessionRecoveryCapsule(root).list(NOW) expect(offered?.work).toEqual({ kind: 'turn', id: 'settled' }) - // The description is captured BEFORE the stop, off the roster the sidebar was still showing; - // eviction clears that roster and settles the rows moments later. + // The description is captured BEFORE the stop, off the records the sidebar was still showing; + // eviction settles them moments later. expect(offered?.activity).toEqual({ state: 'done', prompts: [], diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts index 6cd19475d69f..2beee1b5a59c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts @@ -31,7 +31,7 @@ export type StructuredAgentSessionRestartWitnesses = { export function createStructuredAgentSessionRestartWitnesses(deps: { sessions: ReadonlyMap> getRecord: (sessionId: string) => AgentSessionRecord | null - backgroundTasks: WorkingAtStopInput['backgroundTasks'] + childWork: WorkingAtStopInput['childWork'] capsule?: Pick teardownId: string now: () => number @@ -59,7 +59,7 @@ export function createStructuredAgentSessionRestartWitnesses(deps: { sessionId, session: deps.sessions.get(sessionId), getRecord: deps.getRecord, - backgroundTasks: deps.backgroundTasks, + childWork: deps.childWork, trigger, teardownId: deps.teardownId, now: deps.now() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index 226e6536c9c1..2ea475cf3bab 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -232,8 +232,8 @@ async function restore(sessionIds: readonly string[]) { await restoreStructuredAgentSessionsOnRestart({ openDeps: deps, records: sessionIds.map(recordFor), - reconcile: async () => null, - resolveRecovery: async () => undefined, + reconcile: async () => true, + resolveRecovery: async () => true, serialize: async (_sessionId, task) => task(), hasSession: (sessionId) => sessions.has(sessionId), onReadable: (sessionId, opened) => { @@ -250,7 +250,8 @@ async function restore(sessionIds: readonly string[]) { serialize: async (_sessionId, task) => task(), open, deliveryActive: () => false, - closeStatus: () => undefined + closeStatus: () => undefined, + readChildWork: () => undefined }) const lifetime = lifetimeOver(sessions, async (sessionId) => sessions.get(sessionId) ?? null) return { sessions, lifetime, lifetimeOver } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts index 2a1899ab1ccd..262c9e18d3c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts @@ -10,10 +10,15 @@ // a send does. And a journal it cannot open is not a refusal — the chat shows that failure with a // Retry, so the tab is worth publishing either way. +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { adapterSupportsRecord } from './structured-agent-session-provider-support' import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate' +import { + createReaderReconcile, + reportEachFailureOnce +} from './structured-agent-session-restart-reconcile' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionReveal @@ -51,23 +56,44 @@ export async function revealStructuredAgentSession( } } -/** The host's startup readable-restore sweep: reconcile, resolve, then open each chat's journal. */ +/** The host's startup readable-restore sweep: reconcile, resolve, then open each chat's journal. + * Its lease bookkeeping is a reader's, which never fails a read or startup; startup shares it. */ export function createStructuredAgentSessionHostRestore( deps: StructuredAgentSessionHostDeps, wiring: Omit< ConstructorParameters[0], - 'openDeps' | 'supportsRecord' - > + 'openDeps' | 'supportsRecord' | 'reconcile' | 'resolveRecovery' + > & { + reconcileLeases: (sessionId: string) => Promise + resolveRecovery: (sessionId: string) => Promise + } ): { + reconcileRestartLeases: () => Promise restoreReadableSessions: (sessionIds?: readonly string[]) => Promise } { + const { reconcileLeases, resolveRecovery, ...rest } = wiring + const failures = reportEachFailureOnce(deps.onLeaseReconcileFailure) + const reconcile = createReaderReconcile(reconcileLeases, failures) const restorer = new StructuredAgentSessionReadableRestorer({ openDeps: deps, supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record), - ...wiring + reconcile, + // The next attach or send resolves recovery again, strictly, before it acts. + resolveRecovery: (sessionId) => + resolveRecovery(sessionId).then( + () => true, + (error: unknown) => { + failures.report(error) + return false + } + ), + ...rest }) const gate = new StructuredAgentSessionRestartRestoreGate() return { + reconcileRestartLeases: async () => { + await reconcile('startup') + }, restoreReadableSessions: (sessionIds) => gate.run(() => restorer.restore(sessionIds)) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts index b34de39b74f1..dd878ebe0d7c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts @@ -198,6 +198,29 @@ describe('an interrupted Codex rewind on a chat at rest (R16)', () => { expect(texts).toEqual(['verified history', 'after the rewind']) expect(snapshot.submissions.at(-1)?.dispatchState).toBe('accepted') }) + + it('is settled by the start a /clear makes, and the clear commits', async () => { + await interruptedRewindAtRest() + const before = acquires + + const cleared = await host.conversationCommand(caller, { + command: 'clear', + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: fence(), + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.conversationCommand', + sessionId: SESSION, + fields: { command: 'clear' } + }) + } + }) + expect(cleared).toMatchObject({ ok: true, value: { phase: 'committed', state: 'completed' } }) + expect(acquires - before).toBe(1) + expect(recoverRewind).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.rewind?.phase).toBe('completed') + }) }) describe('a rewind asked of a chat at rest (P2-23)', () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index e855ae97ab27..6358cdfe0f06 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -74,7 +74,7 @@ export async function rewindStructuredAgentSession( ) { return rewindRefusal('outcome-unknown') } - if (conversationCommandBlocked(ctx, record)) { + if (conversationCommandBlocked(ctx, record, context.readChildWork(sessionId))) { return rewindRefusal('busy') } if (ctx.journal.isReadOnly) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index fbe81be21ac1..86873faf6b0a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -145,7 +145,7 @@ async function errorStatuses(): Promise { /** The child timed out or exited: its lease is handed back and the host holds no session. */ async function loseOwner(): Promise { - await host.close(SESSION) + await host.close(SESSION, 'evict') expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', ownerProcess: null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts index 2d868797024c..17bb4b0ee1fc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -8,7 +8,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' -import type * as DurableFileWrite from '../../durable-file-write' +import type * as AgentSessionRecordRows from '../../runtime/agent-session-record-rows' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { @@ -31,21 +31,16 @@ const CALLER = { callerKey: 'client-1' } const publishFault = vi.hoisted(() => ({ failOnPublish: 0, publishCount: 0 })) -vi.mock('../../durable-file-write', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../../runtime/agent-session-record-rows', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, - renameDurable: async (tmpPath: string, finalPath: string) => { - if (finalPath.endsWith('agent-sessions.json')) { - publishFault.publishCount += 1 - } - if ( - finalPath.endsWith('agent-sessions.json') && - publishFault.publishCount === publishFault.failOnPublish - ) { + writeAgentSessionStoreRows: (...args: Parameters) => { + publishFault.publishCount += 1 + if (publishFault.publishCount === publishFault.failOnPublish) { throw new Error('simulated crash before failed-settlement publish') } - return actual.renameDurable(tmpPath, finalPath) + return actual.writeAgentSessionStoreRows(...args) } } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.test.ts index 819c242fdfb7..3fc7dbee0a99 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { structuredAgentSessionShownStatus } from './structured-agent-session-shown-work' -import { submission } from './structured-agent-session-restart-resume-test-harness' +import { childRecord, submission } from './structured-agent-session-restart-resume-test-harness' describe('whether a session shows as working', () => { // The status feed scopes unanswered sends to the lease fence; a stale one would otherwise offer a @@ -16,7 +16,14 @@ describe('whether a session shows as working', () => { expect( structuredAgentSessionShownStatus( journal, - [{ id: 'watch', kind: 'monitor', description: 'Watch CI', state: 'working' }], + [ + childRecord({ + id: 'watch', + kind: 'monitor', + description: 'Watch CI', + state: 'monitoring' + }) + ], 1 ).state ).not.toBe('done') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.ts index d50f05709879..4ed41e8c6884 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.ts @@ -1,15 +1,15 @@ // Whether a structured session is doing work, asked the one way the product answers it. // -// The status feed publishes the lead's journal status beside the provider's live background roster, -// and the store ingest folds the two into the row the sidebar shows. The quit snapshot composes the +// The status feed publishes the lead's journal status beside the host's child records, and the +// store ingest folds the two into the row the sidebar shows. The quit snapshot composes the // same two inputs through the same fold, so it cannot offer a different set of chats than the one // the user saw working. -import type { AgentSessionBackgroundTask } from '../../../shared/agent-session-background-task-wire' import type { AgentJournalRenderItem, AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { structuredAgentSessionAgentStatus, type StructuredAgentSessionAgentStatus @@ -23,13 +23,13 @@ export function structuredAgentSessionShownStatus( items: readonly AgentJournalRenderItem[] submissions: readonly AgentJournalSubmission[] }, - backgroundTasks: readonly AgentSessionBackgroundTask[] | null | undefined, + childWork: readonly AgentChildWorkView[] | undefined, /** The session's lease fence, as the status feed passes it: a send from an older one is not work. */ fence: number | undefined ): StructuredAgentSessionAgentStatus { const status = projectStructuredAgentSessionStatus(journal.items, journal.submissions, fence) return structuredAgentSessionAgentStatus({ status, - ...(backgroundTasks ? { backgroundTasks: [...backgroundTasks] } : {}) + ...(childWork ? { childWork } : {}) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts index 97c951b4517d..d08f536daef4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts @@ -17,11 +17,16 @@ import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' +import type { + StructuredAgentSessionChildEndCause, + StructuredAgentSessionEndedEvent +} from './structured-agent-session-adapter' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' export type StructuredAgentSessionTurnVerdict = - | { state: 'interrupted'; completedAt: number } + /** `cancellation` only for a stop the user aimed at this chat: every other cut is news. */ + | { state: 'interrupted'; completedAt: number; outcome?: 'cancellation' } | { state: 'unverifiable' } export const UNVERIFIABLE_TURN_VERDICT: StructuredAgentSessionTurnVerdict = { @@ -56,6 +61,45 @@ export function turnVerdictFromDeathEvidence( } } +/** + * The one mapping from why a provider child ended to what the turn it cut reads as. Each adapter + * settles its own open turn through it on `ended`, and the host's fallback settles through it any + * turn no adapter did. Only a stop the user aimed at this chat is their cancellation. + */ +export function turnVerdictForChildEnd( + cause: StructuredAgentSessionChildEndCause, + completedAt: number +): Extract { + return stopIsTheUsers(cause) + ? { state: 'interrupted', completedAt, outcome: 'cancellation' } + : { state: 'interrupted', completedAt } +} + +/** Whether the user asked for this end. Only then is a cut turn their cancellation. */ +export function stopIsTheUsers(cause: StructuredAgentSessionChildEndCause): boolean { + switch (cause) { + case 'user-stop': + case 'user-close': + return true + case 'host-stop': + case 'evict': + case 'exit': + case 'attach-failed': + return false + } +} + +/** Why the child an `ended` event reports ended: who asked for a close, else an exit it had. A + * requested close with no cause named is the host's own. */ +export function childEndCauseOfEndedEvent( + event: { type: 'ended' } & Partial> +): StructuredAgentSessionChildEndCause { + if (event.cause === 'unexpected-exit') { + return 'exit' + } + return event.stopCause ?? 'host-stop' +} + /** Revises every still-running lifecycle item in place, keeping its identity and start. */ export function runningTurnLifecycleRevisions( items: readonly AgentJournalRenderItem[], @@ -125,5 +169,10 @@ function settledLifecycle( } // A renewal can predate the turn, which started with its owner alive; it never ends before that. const began = Math.max(lifecycle.requestedAt ?? 0, lifecycle.startedAt ?? 0) - return { ...kept, state: verdict.state, completedAt: Math.max(verdict.completedAt, began) } + return { + ...kept, + state: verdict.state, + completedAt: Math.max(verdict.completedAt, began), + ...(verdict.outcome ? { outcome: verdict.outcome } : {}) + } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts new file mode 100644 index 000000000000..d4f8bce00332 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts @@ -0,0 +1,236 @@ +// The lease reconcile is bookkeeping: a write that fails is reported, and startup and every read +// carry on. Nothing is owed after it, because an unreconciled lease grants no writer and the next +// send reconciles every lease again before it acts. Over records a newer Orca wrote, the reconcile +// adjudicates in memory and writes nothing. + +import { cp, readdir, readFile, rm } from 'node:fs/promises' +import { afterEach, expect, it, vi } from 'vitest' +import type * as AgentSessionRecordRows from '../../runtime/agent-session-record-rows' +import { + openTestAgentSessionRecordStore, + seedTestAgentSessionStoreFromNewerBuild +} from '../../runtime/agent-session-record-store-test-harness' +import { journalDatabasePath } from '../agent-session-journal/journal-host-database' +import { JOURNAL_NEWER_SCHEMA_MESSAGE } from '../agent-session-journal/journal-open-failure' +import { + StructuredAgentSessionHost, + type StructuredAgentSessionHostDeps +} from './structured-agent-session-host' +import { + adapter, + attach, + CALLER, + envelope, + hostTestState, + replaceHostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestMessage +} from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' + +const writes = vi.hoisted(() => ({ failing: false })) + +vi.mock('../../runtime/agent-session-record-rows', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeAgentSessionStoreRows: (...args: Parameters) => { + if (writes.failing) { + // What SQLite throws when the disk will not take the commit. + throw Object.assign(new Error('disk I/O error'), { code: 'ERR_SQLITE_ERROR', errcode: 10 }) + } + return actual.writeAgentSessionStoreRows(...args) + } + } +}) + +const IO_ERROR = expect.objectContaining({ message: 'disk I/O error' }) + +const relaunchedRoots: string[] = [] + +afterEach(async () => { + writes.failing = false + await Promise.all( + relaunchedRoots.splice(0).map((dir) => rm(dir, { recursive: true, force: true })) + ) +}) + +/** A host with one chat, relaunched over a copy of its files; `newer` marks the copied records as + * a newer Orca's. */ +async function relaunch( + newer = false, + probeOwner: StructuredAgentSessionHostDeps['probeOwner'] = async () => ({ + outcome: 'pid-absent' + }) +) { + const dying = hostTestState() + await attach() + // An empty renewal queues behind every record write, so they are on disk. + await dying.store.renewLeases([]) + const relaunched = `${dying.root}-relaunched` + relaunchedRoots.push(relaunched) + // A dead process holds no lock. + await cp(dying.root, relaunched, { + recursive: true, + filter: (source) => !source.includes('.lock') + }) + if (newer) { + await seedTestAgentSessionStoreFromNewerBuild(relaunched) + } + const store = await openTestAgentSessionRecordStore(relaunched) + const onLeaseReconcileFailure = vi.fn() + const host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalDatabase: openTestJournalHostDatabase(relaunched), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-next', + probeOwner, + stopOwnerProcess: () => { + throw new Error('an owner not proven alive must not be stopped') + }, + now: () => NOW, + onLeaseReconcileFailure + }) + replaceHostTestState({ store, host }) + return { host, store, stateDirectory: relaunched, onLeaseReconcileFailure } +} + +it('reports a startup reconcile whose store write fails, and does not reject', async () => { + const { host, store, onLeaseReconcileFailure } = await relaunch() + + writes.failing = true + await expect(host.reconcileRestartLeases()).resolves.toBeUndefined() + + expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() + expect(onLeaseReconcileFailure).toHaveBeenCalledWith(IO_ERROR) + // Nothing was adjudicated, so the lease still grants no writer. + expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(true) +}) + +it('reconciles the chat on its next send once the store can be written again', async () => { + const { host, store, onLeaseReconcileFailure } = await relaunch() + writes.failing = true + await host.reconcileRestartLeases() + expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() + writes.failing = false + + const body = hostTestMessage('sent after a startup reconcile failed') + await expect( + host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).resolves.toMatchObject({ ok: true }) + + // The send is queued; delivering it starts the agent, and that start reconciles the lease. + await vi.waitFor(() => expect(hostTestState().dispatch).toHaveBeenCalledOnce(), { + timeout: 10_000 + }) + expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(false) + expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() + // Before the relaunched directory is removed, so the child's wind-down can write its lease. + await host.flushAllStreamedEvents() +}) + +it('adjudicates records a newer Orca wrote in memory only, and writes nothing', async () => { + const { host, store, stateDirectory, onLeaseReconcileFailure } = await relaunch(true) + expect(store.readOnly).toBe(true) + const path = journalDatabasePath(stateDirectory) + const bytes = await readFile(path) + const files = await readdir(stateDirectory) + + await expect(host.reconcileRestartLeases()).resolves.toBeUndefined() + + expect(onLeaseReconcileFailure).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + unreconciled: false, + claimStatus: 'released' + }) + expect(await readFile(path)).toEqual(bytes) + expect(await readdir(stateDirectory)).toEqual(files) +}) + +it('restores a chat for reading while the reconcile keeps failing, and reports it once', async () => { + const { host, store, onLeaseReconcileFailure } = await relaunch() + writes.failing = true + await host.reconcileRestartLeases() + + await expect(host.restoreReadableSessions([SESSION])).resolves.toBeUndefined() + + expect(host.hasSession(SESSION)).toBe(true) + expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(true) + expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() + expect(onLeaseReconcileFailure).toHaveBeenCalledWith(IO_ERROR) +}) + +it('refuses a send over records a newer Orca wrote with the update words', async () => { + const { host } = await relaunch(true) + await host.reconcileRestartLeases() + + const body = hostTestMessage('sent to a chat a newer Orca saved') + await expect( + host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).resolves.toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalWrittenByNewerOrca' }, + message: JOURNAL_NEWER_SCHEMA_MESSAGE + } + }) +}) + +it('restores a chat for reading from records a newer Orca wrote', async () => { + const { host, stateDirectory, onLeaseReconcileFailure } = await relaunch(true) + const path = journalDatabasePath(stateDirectory) + const bytes = await readFile(path) + + await expect(host.restoreReadableSessions([SESSION])).resolves.toBeUndefined() + + expect(host.hasSession(SESSION)).toBe(true) + expect(onLeaseReconcileFailure).not.toHaveBeenCalled() + expect(await readFile(path)).toEqual(bytes) +}) + +// A chat whose owner could not be proven gone is left recovering; the next attach or send retries it. +it('restores a chat for reading when resolving its recovery cannot write the store', async () => { + const { host, store, onLeaseReconcileFailure } = await relaunch(false, async () => ({ + outcome: 'indeterminate', + reason: 'probe' + })) + await host.reconcileRestartLeases() + expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering') + writes.failing = true + + await expect(host.restoreReadableSessions([SESSION])).resolves.toBeUndefined() + + expect(host.hasSession(SESSION)).toBe(true) + expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering') + expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() + expect(onLeaseReconcileFailure).toHaveBeenCalledWith(IO_ERROR) +}) + +it.each([ + ['startup reconcile', (host: StructuredAgentSessionHost) => host.reconcileRestartLeases()], + ['read restore', (host: StructuredAgentSessionHost) => host.restoreReadableSessions([SESSION])] +])('keeps the %s resolving when the failure sink throws', async (_step, read) => { + const { host, onLeaseReconcileFailure } = await relaunch() + const sinkError = new Error('error sink failed') + onLeaseReconcileFailure.mockImplementation(() => { + throw sinkError + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + writes.failing = true + try { + await expect(read(host)).resolves.toBeUndefined() + + expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('reporting a lease bookkeeping failure failed'), + expect.objectContaining({ failure: IO_ERROR, sinkError }) + ) + } finally { + warn.mockRestore() + } +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.ts new file mode 100644 index 000000000000..10a447292627 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.ts @@ -0,0 +1,65 @@ +// What the status summary carries of a session's child records, and when a change to them is +// worth re-broadcasting to every session list. +// +// The summary reaches every subscriber, remote ones included, so it carries each child's facts but +// not its per-tick freshness: usage never, and an evidence clock only once it has moved far enough +// to change the minute a "no update" reading shows. The background-task channel, which only an +// open chat subscribes to, carries every tick. + +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { AgentSessionBackgroundTask } from '../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import { agentChildWorkViewsEqual } from '../../../shared/agent-status-child-work-view-wire' +import { structuredChildWorkLegacyTasks } from '../../../shared/structured-agent-session-child-work-legacy' +import { structuredRunningChildWork } from '../../../shared/agent-child-work-listing' + +/** An evidence clock that advanced by less than this does not re-broadcast a summary. + * Invariant: every reader of the summary's child clocks shows staleness no finer than this (today, + * whole minutes); a reader that needs finer freshness reads the background-task channel, which + * carries every tick. The comparison is index-wise, so it relies on the store's read keeping + * insertion order: a read that sorted would turn every reorder into a broadcast. */ +export const SUMMARY_CHILD_CLOCK_TOLERANCE_MS = 60_000 + +export type StructuredStatusChildWork = { + children?: AgentChildWorkView[] + backgroundTasks?: AgentSessionBackgroundTask[] +} + +/** The summary's child fields: the running views without usage, since the sidebar lists running + * children only, and the live legacy tasks an older client reads, derived from the same views so + * the two cannot disagree. */ +export function structuredStatusChildWork( + views: readonly AgentChildWorkView[] | undefined, + provider: AgentSessionHandleProvider +): StructuredStatusChildWork { + const running = views ? structuredRunningChildWork(views) : [] + if (running.length === 0) { + return {} + } + const children = running.map(({ totalTokens: _totalTokens, ...view }) => view) + // `tasks` only, the live rows: an old client folds every listed task into the parent, and + // reads a failed one's legacy `blocked` as still running. Dies with the legacy shapes; see the + // death condition in `structured-agent-session-child-work-legacy`. + const { tasks } = structuredChildWorkLegacyTasks(children, provider) + return { children, ...(tasks ? { backgroundTasks: tasks } : {}) } +} + +export function structuredStatusChildrenEqual( + a: readonly AgentChildWorkView[] | undefined, + b: readonly AgentChildWorkView[] | undefined +): boolean { + return agentChildWorkViewsEqual(a, b, SUMMARY_CHILD_CLOCK_TOLERANCE_MS) +} + +/** The user's newest send the provider accepted (a message, a steer or a command), keyed by the + * journal epoch: its change is the user's next turn. A turn the provider opens itself, such as + * Claude waking the agent when a background task ends, carries no send, and a subagent's turn is + * never one; a rewind replaces the epoch. */ +export function newestAcceptedSendKey( + epoch: string, + submissions: readonly AgentJournalSubmission[] +): string { + const accepted = submissions.findLast((submission) => submission.dispatchState === 'accepted') + return `${epoch}:${accepted?.clientMessageId ?? ''}` +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts new file mode 100644 index 000000000000..c4e8100c30ae --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts @@ -0,0 +1,349 @@ +// The status summary reads a session's child records from the sink its row landed in, and derives +// the legacy task list an older client folds from those same records. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../shared/agent-session-wire' +import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import { + StructuredAgentSessionStatusFeed, + type StructuredAgentSessionStatusSink +} from './structured-agent-session-status-feed' +import { + indexedStatusFeedSession, + statusFeedChildView as childView +} from './structured-agent-session-status-feed-test-session' + +const SESSION = 'status-session' +const USER_IDENTITY = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-1', + ordinal: 1 +} as const +/** The legacy row an older client reads for `childView()`, derived from it. These sessions are + * Codex's, so a subagent keeps the id its row carried before views. */ +const LEGACY_TASK = { + id: 'codex-agent:task-1', + kind: 'agent', + name: 'deep_review', + state: 'working', + startedAt: 100, + stoppable: true +} as const + +let root: string +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-agent-status-feed-child-records-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +async function feedWithChildren(provider: AgentSessionHandleProvider = 'codex') { + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + stateDirectory: join(root, SESSION) + }) + const views: { current: () => AgentChildWorkView[] } = { current: () => [] } + const admitted: AgentChildWorkEvidence[][] = [] + const changed: string[] = [] + const sink: StructuredAgentSessionStatusSink = { + publish: () => {}, + forget: () => {}, + publishChildWork: (_subject, evidence) => admitted.push(evidence), + readChildWork: () => views.current() + } + let now = 1_000 + const feed = new StructuredAgentSessionStatusFeed({ + sessions: new Map([[SESSION, indexedStatusFeedSession({ journal, provider })]]), + getRecord: () => null, + now: () => (now += 1), + statusSink: () => sink, + onChildWorkChanged: (sessionId) => changed.push(sessionId) + }) + const events: AgentSessionStatusEvent[] = [] + feed.subscribe({ id: 'list-1', emit: (event) => events.push(event) }) + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + return { journal, feed, events, views, admitted, changed } +} + +function lastSummary(events: AgentSessionStatusEvent[]): AgentSessionStatusSummary | undefined { + const last = events.at(-1) + return last?.type === 'status' ? last.session : undefined +} + +describe('structured status summary child records', () => { + it('projects live child records and republishes a record-only state change', async () => { + const { journal, feed, events, views: read } = await feedWithChildren() + let views = [childView()] + read.current = () => views + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + feed.publish(SESSION, journal) + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ backgroundTasks: [LEGACY_TASK], children: views }) + }) + + // No journal change: only the record's state moved. + views = [childView({ state: 'waiting' })] + const before = events.length + feed.publish(SESSION, journal) + expect(events).toHaveLength(before + 1) + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ + backgroundTasks: [expect.objectContaining({ state: 'waiting' })] + }) + }) + + // An identical projection is suppressed. + feed.publish(SESSION, journal) + expect(events).toHaveLength(before + 1) + }) + + it('omits usage and a ticking evidence clock so a progress tick never re-broadcasts', async () => { + const { journal, feed, events, views: read } = await feedWithChildren() + const operation = { toolName: 'Bash', input: 'npm test', basis: 'reported' as const } + let views = [childView({ totalTokens: 10, operation: { ...operation, observedAt: 100 } })] + read.current = () => views + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + feed.publish(SESSION, journal) + const before = events.length + + // A `task_progress` frame moves usage, which no status-summary reader renders, and the + // child's evidence clocks; re-broadcasting the whole summary per frame would cost every + // remote subscriber. The chat strip's channel carries both per tick. + views = [ + childView({ + totalTokens: 4_200, + observedAt: 59_000, + operation: { ...operation, observedAt: 59_000 } + }) + ] + feed.publish(SESSION, journal) + expect(events).toHaveLength(before) + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ + backgroundTasks: [LEGACY_TASK], + children: [childView({ operation: { ...operation, observedAt: 100 } })] + }) + }) + + // The clock re-broadcasts once it moved far enough to change a "no update" minute. + views = [childView({ observedAt: 60_101, operation: { ...operation, observedAt: 60_101 } })] + feed.publish(SESSION, journal) + expect(events).toHaveLength(before + 1) + + // What the child is doing, or its state, re-broadcasts at once. + views = [ + childView({ + observedAt: 60_200, + operation: { ...operation, toolName: 'Read', observedAt: 60_200 } + }) + ] + feed.publish(SESSION, journal) + expect(events).toHaveLength(before + 2) + views = [childView({ state: 'waiting', observedAt: 60_300 })] + feed.publish(SESSION, journal) + expect(events).toHaveLength(before + 3) + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ + backgroundTasks: [expect.objectContaining({ state: 'waiting' })] + }) + }) + }) + + // The sidebar and the strip list running children only; a finished one is on neither. + it('lists only running children on the summary, and no finished one in any of its lists', async () => { + const { feed, events, views } = await feedWithChildren() + const failed = childView({ + id: 'child-2', + providerId: 'task-2', + state: 'done', + membership: 'settled', + outcome: 'failed', + settledAt: 150, + observedAt: 150, + lastMessage: 'Exit code 1' + }) + views.current = () => [childView(), failed] + feed.publish(SESSION) + // An older client reads a failed task's legacy `blocked` as still running. + expect(lastSummary(events)).toMatchObject({ + backgroundTasks: [LEGACY_TASK], + children: [childView()] + }) + views.current = () => [failed] + feed.publish(SESSION) + expect(lastSummary(events)).not.toHaveProperty('children') + expect(lastSummary(events)).not.toHaveProperty('backgroundTasks') + }) + + it('keeps a finished child on the summary while a shell it launched still runs', async () => { + const { feed, events, views } = await feedWithChildren() + const owner = childView({ state: 'done', membership: 'settled', outcome: 'succeeded' }) + const shell = childView({ + id: 'child-2', + providerId: 'task-2', + kind: 'command', + parentChildWorkId: 'child-1' + }) + views.current = () => [owner, shell] + feed.publish(SESSION) + expect(lastSummary(events)?.children).toEqual([owner, shell]) + }) + + it("keeps a Claude child's own id, and a Codex subagent's the id its row always carried", async () => { + const shell = childView({ + id: 'child-2', + providerId: 'codex-command:primary:cmd-1', + kind: 'command' + }) + for (const [provider, agentId] of [ + ['claude', 'task-1'], + ['codex', 'codex-agent:task-1'] + ] as const) { + const { feed, events, views } = await feedWithChildren(provider) + views.current = () => [childView(), shell] + feed.publish(SESSION) + expect(lastSummary(events)?.backgroundTasks?.map((task) => task.id)).toEqual([ + agentId, + 'codex-command:primary:cmd-1' + ]) + // The views name the child as its records do; only the legacy shape is mapped. + expect(lastSummary(events)?.children?.map((view) => view.providerId)).toEqual([ + 'task-1', + 'codex-command:primary:cmd-1' + ]) + await journals.closeAll() + await rm(join(root, SESSION), { recursive: true, force: true }) + } + }) + + it('re-reads the records once child work is admitted, and tells the strip', async () => { + const { feed, events, views, admitted, changed } = await feedWithChildren() + const edge: AgentChildWorkEvidence = { type: 'session-ended', observedAt: 5 } + feed.publish(SESSION) + const before = events.length + views.current = () => [childView()] + feed.publishChildWork(SESSION, [edge]) + expect(admitted).toEqual([[edge]]) + expect(changed).toContain(SESSION) + expect(events).toHaveLength(before + 1) + expect(lastSummary(events)?.children).toEqual([childView()]) + }) + + it("retires finished children at the user's next accepted send, not at a turn nobody sent", async () => { + const { journal, feed, admitted } = await feedWithChildren() + const turn = (turnId: string, state: 'running' | 'completed', agentId?: string) => + journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 0 }, + { kind: 'turn', turnId, state }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE, ...(agentId ? { agentId } : {}) } + ) + const send = async (clientMessageId: string) => { + await journal.appendSubmission({ + clientMessageId, + payloadFingerprint: clientMessageId, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }, + fence: 1 + }) + feed.publish(SESSION) + } + const accept = async (clientMessageId: string) => { + await journal.resolveDispatch({ + clientMessageId, + state: 'accepted', + providerIdentity: { ...USER_IDENTITY, ordinal: USER_IDENTITY.ordinal + 1 }, + fence: 1 + }) + feed.publish(SESSION) + } + const retirements = () => admitted.flat().filter((edge) => edge.type === 'turn-started') + feed.publish(SESSION) + await send('send-1') + // Written but not taken: the provider may still refuse it. + expect(retirements()).toHaveLength(0) + await accept('send-1') + expect(retirements()).toHaveLength(1) + await turn('turn-1', 'running') + feed.publish(SESSION) + await turn('turn-1', 'completed') + feed.publish(SESSION) + // Neither a subagent's turn nor a turn the provider opened on its own is the user's. + await turn('child-turn', 'running', 'child-thread') + feed.publish(SESSION) + await turn('wake-turn', 'running') + feed.publish(SESSION) + expect(retirements()).toHaveLength(1) + + await send('send-2') + await accept('send-2') + feed.publish(SESSION) + expect(retirements()).toHaveLength(2) + + // A rewind replaces the conversation, as the user's own action. + await journal.replaceEpochItems('handle_forked', 1, []) + feed.publish(SESSION) + expect(retirements()).toHaveLength(3) + }) + + it("forgets a closed session's children in the projection it keeps", async () => { + const { feed, events, views } = await feedWithChildren() + views.current = () => [childView()] + feed.publish(SESSION) + expect(lastSummary(events)?.children).toHaveLength(1) + feed.close(SESSION) + expect(lastSummary(events)).not.toHaveProperty('children') + expect(lastSummary(events)).not.toHaveProperty('backgroundTasks') + }) + + it('stops listing children once the row leaves the sink, even with no close', async () => { + const { feed, events, views } = await feedWithChildren() + views.current = () => [childView()] + feed.publish(SESSION) + expect(lastSummary(events)?.children).toHaveLength(1) + feed.forget(SESSION) + expect(lastSummary(events)).not.toHaveProperty('children') + // A reload reads the retained projection: it must not list what the store no longer holds. + feed.subscribe({ id: 'list-2', emit: (event) => events.push(event) }) + const snapshot = events.at(-1) + expect(snapshot?.type === 'snapshot' ? snapshot.sessions[0] : null).not.toHaveProperty( + 'children' + ) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts index da7275cc6418..130f5961bc0f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts @@ -1,9 +1,12 @@ +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' -export function indexedStatusFeedSession(session: { +export function indexedStatusFeedSession

(session: { journal: AgentSessionJournal child?: (StructuredAgentSessionProviderChildIdentity & { phase: 'starting' | 'ready' }) | null + provider?: P }) { return { journal: session.journal, @@ -15,7 +18,25 @@ export function indexedStatusFeedSession(session: { workspaceId: 'workspace-1', workspaceKind: 'git-worktree' as const }, - provider: 'codex' as const + provider: session.provider ?? ('codex' as const) } } } + +/** One child record as the views a status sink hands the feed. */ +export function statusFeedChildView(over: Partial = {}): AgentChildWorkView { + return { + id: 'child-1', + providerId: 'task-1', + kind: 'agent', + name: 'deep_review', + agentType: 'deep_review', + state: 'working', + membership: 'live', + firstObservedAt: 100, + observedAt: 100, + stoppable: true, + invocation: { invocationId: 'toolu_1', generation: 1 }, + ...over + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index ad23177bdd3a..8062b740bcbf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -6,15 +6,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' import type { - AgentSessionBackgroundTask, AgentSessionStatusEvent, AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { createClaudeJournalTranslator } from '../../claude/claude-structured-journal-translation' import { publishCodexTurnLifecycle } from '../../codex/codex-structured-journal-translation-turns' import { createDeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' -import { indexedStatusFeedSession as indexed } from './structured-agent-session-status-feed-test-session' +import { + indexedStatusFeedSession as indexed, + statusFeedChildView +} from './structured-agent-session-status-feed-test-session' import { StructuredAgentSessionStatusFeed, type StructuredAgentSessionStatusFeedDeps, @@ -67,14 +70,17 @@ function feedFor( sessions: Map[0]>, record: Partial | null = null, onStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'], - readBackgroundTasks?: StructuredAgentSessionStatusFeedDeps['readBackgroundTasks'], + readChildWork?: () => AgentChildWorkView[], statusSink?: StructuredAgentSessionStatusSink ) { let now = 1_000 + // The summary reads child records where the host keeps them: the sink its row landed in. + const sink = + statusSink ?? + (readChildWork ? { publish: () => {}, forget: () => {}, readChildWork } : undefined) const feed = new StructuredAgentSessionStatusFeed({ ...(onStatusChanged ? { onStatusChanged } : {}), - ...(statusSink ? { statusSink: () => statusSink } : {}), - ...(readBackgroundTasks ? { readBackgroundTasks } : {}), + ...(sink ? { statusSink: () => sink } : {}), sessions: { get: (sessionId: string) => { const session = sessions.get(sessionId) @@ -638,10 +644,9 @@ describe('StructuredAgentSessionStatusFeed', () => { ) const snapshot = vi.spyOn(journal, 'snapshot') let taskState: 'working' | 'waiting' = 'working' - const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), null, undefined, () => ({ - state: 'monitoring', - tasks: [{ id: 'child', kind: 'agent', state: taskState }] - })) + const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), null, undefined, () => [ + statusFeedChildView({ state: taskState }) + ]) for (let tick = 1; tick <= 100; tick++) { taskState = tick % 2 === 1 ? 'waiting' : 'working' feed.publish(SESSION) @@ -680,90 +685,6 @@ describe('StructuredAgentSessionStatusFeed', () => { feed.publish(SESSION) expect(events.at(-1)).toMatchObject({ type: 'status', session: { status: 'idle' } }) }) - - it('projects live background tasks and republishes a task-only state change', async () => { - const journal = await openJournal() - let tasks = [ - { id: 'task-1', kind: 'agent' as const, name: 'deep_review', state: 'working' as const } - ] - const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), null, undefined, () => ({ - state: 'monitoring', - tasks - })) - await journal.appendItem( - USER_IDENTITY, - { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - feed.publish(SESSION, journal) - expect(events.at(-1)).toEqual({ - type: 'status', - session: expect.objectContaining({ - backgroundTasks: [{ id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working' }] - }) - }) - - // No journal change: only the task state moved. - tasks = [{ id: 'task-1', kind: 'agent', name: 'deep_review', state: 'waiting' as never }] - const before = events.length - feed.publish(SESSION, journal) - expect(events).toHaveLength(before + 1) - expect(events.at(-1)).toEqual({ - type: 'status', - session: expect.objectContaining({ - backgroundTasks: [expect.objectContaining({ state: 'waiting' })] - }) - }) - - // An identical projection is suppressed. - feed.publish(SESSION, journal) - expect(events).toHaveLength(before + 1) - }) - - it('omits task usage so a progress tick never re-broadcasts the summary', async () => { - const journal = await openJournal() - let tasks: AgentSessionBackgroundTask[] = [ - { id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working', totalTokens: 10 } - ] - const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), null, undefined, () => ({ - state: 'monitoring', - tasks - })) - await journal.appendItem( - USER_IDENTITY, - { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - feed.publish(SESSION, journal) - const before = events.length - - // A `task_progress` frame moves only usage, which no status-summary reader renders; - // re-broadcasting the whole summary per frame would cost every remote subscriber. - tasks = [ - { id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working', totalTokens: 4_200 } - ] - feed.publish(SESSION, journal) - expect(events).toHaveLength(before) - expect(events.at(-1)).toEqual({ - type: 'status', - session: expect.objectContaining({ - backgroundTasks: [{ id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working' }] - }) - }) - - // A state change on the same task still reaches subscribers. - tasks = [ - { id: 'task-1', kind: 'agent', name: 'deep_review', state: 'waiting', totalTokens: 4_200 } - ] - feed.publish(SESSION, journal) - expect(events).toHaveLength(before + 1) - expect(events.at(-1)).toEqual({ - type: 'status', - session: expect.objectContaining({ - backgroundTasks: [expect.objectContaining({ state: 'waiting' })] - }) - }) - }) }) /** diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 22a61a006bb6..4f717d2c31be 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -10,23 +10,24 @@ // each chat is reopened. Restart is the one boundary that forgets, and restoring readable sessions // republishes them. -import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { normalizeOptionalField } from '../../../shared/agent-status-field-normalization' -import { isAgentStatusHeldOpenByChildWork } from '../../../shared/agent-lead-status-fold' import { AGENT_MODEL_MAX_LENGTH } from '../../../shared/agent-status-types' -import { - agentSessionBackgroundTasksEqual, - type AgentSessionBackgroundTaskState, - type AgentSessionStatusEvent, - type AgentSessionStatusSummary +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' -import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' -import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' import { structuredAgentSessionProviderSessionMetadata } from './structured-agent-session-history-result' +import { structuredStatusChildWork } from './structured-agent-session-status-child-work' +import { + StructuredAgentSessionJournalProjections, + type StructuredAgentSessionStatusState +} from './structured-agent-session-status-journal-projection' +import { structuredStatusSummariesEqual } from './structured-agent-session-status-summary-equality' import { StructuredAgentSessionStatusOwnership, type StructuredAgentSessionStatusSink @@ -34,9 +35,7 @@ import { export type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-ownership' -export type StructuredAgentSessionStatusState = ReturnType< - typeof projectStructuredAgentSessionStatusState -> +export type { StructuredAgentSessionStatusState } from './structured-agent-session-status-journal-projection' export type StructuredAgentSessionStatusSubscriber = { id: string @@ -57,56 +56,14 @@ export type StructuredAgentSessionStatusFeedDeps = { * of state the host already knew (restore, an arriving subscriber) rather than a journal edge. */ onStatusChanged?: (summary: AgentSessionStatusSummary, options: { replay: boolean }) => void /** Resolved on every call: the host builds this feed in a field initializer, before its own - * deps are assigned. */ + * deps are assigned. The sink holds the session's child records; the summary reads them there. */ statusSink?: () => StructuredAgentSessionStatusSink | undefined - /** Live provider-owned background tasks for the summary, so session lists can - * render subagent children. Optional: a provider without the hook projects none. */ - readBackgroundTasks?: (sessionId: string) => AgentSessionBackgroundTaskState | null | undefined + /** The session's child records changed, so every other reader of them republishes. */ + onChildWorkChanged?: (sessionId: string) => void /** The session's agent proved a start: its row's phase became `ready`. */ onAgentStarted?: (sessionId: string) => void } -function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSummary): boolean { - return ( - a.workspaceId === b.workspaceId && - a.agent === b.agent && - a.status === b.status && - a.hostExecutionOwned === b.hostExecutionOwned && - a.hostExecutionPhase === b.hostExecutionPhase && - a.hostExecutionChild?.generation === b.hostExecutionChild?.generation && - a.hostExecutionChild?.fence === b.hostExecutionChild?.fence && - a.rewindBlockedReason === b.rewindBlockedReason && - // A moved state clock changes ranking; row activity alone, including a subagent's, does not. - // An idle state the journal cannot date still republishes, since readers date it by `updatedAt`, - // and so does one live child work holds open: readers take each publish as its evidence. - a.statusStartedAt === b.statusStartedAt && - (a.status !== 'idle' || - a.updatedAt === b.updatedAt || - (a.statusStartedAt !== undefined && !isIdleHeldOpenByChildWork(b))) && - a.latestPrompt === b.latestPrompt && - a.model === b.model && - a.toolName === b.toolName && - a.toolInput === b.toolInput && - a.lastAssistantMessage === b.lastAssistantMessage && - a.turnOutcome === b.turnOutcome && - agentSessionBackgroundTasksEqual(a.backgroundTasks, b.backgroundTasks) && - agentProviderSessionsEqual(undefined, a.providerSession, b.providerSession) - ) -} - -function isIdleHeldOpenByChildWork(summary: AgentSessionStatusSummary): boolean { - return ( - summary.status === 'idle' && - isAgentStatusHeldOpenByChildWork( - structuredAgentSessionAgentStatus({ - status: summary.status, - backgroundTasks: summary.backgroundTasks, - turnOutcome: summary.turnOutcome - }) - ) - ) -} - /** Wire the host's own deps into a feed; keeps the host at one call site. * `deps` is a thunk because the host builds the feed in a field initializer, * before its constructor parameters are assigned. */ @@ -115,26 +72,22 @@ export function createStructuredAgentSessionHostStatusFeed(args: { now: () => number deps: () => { store: { getRecord: (sessionId: string) => AgentSessionRecord | null } - adapter: { - backgroundTaskState?: ( - sessionId: string - ) => AgentSessionBackgroundTaskState | null | undefined - } onSessionStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'] statusSink?: StructuredAgentSessionStatusSink } onAgentStarted?: (sessionId: string) => void + onChildWorkChanged?: (sessionId: string) => void }): StructuredAgentSessionStatusFeed { return new StructuredAgentSessionStatusFeed({ sessions: args.sessions, getRecord: (sessionId) => args.deps().store.getRecord(sessionId), now: args.now, onStatusChanged: (summary, options) => args.deps().onSessionStatusChanged?.(summary, options), - readBackgroundTasks: (sessionId) => args.deps().adapter.backgroundTaskState?.(sessionId), // Resolved per call for the same reason the other deps are: the host builds this feed in a // field initializer, before its constructor parameters are assigned. statusSink: () => args.deps().statusSink, - ...(args.onAgentStarted ? { onAgentStarted: args.onAgentStarted } : {}) + ...(args.onAgentStarted ? { onAgentStarted: args.onAgentStarted } : {}), + ...(args.onChildWorkChanged ? { onChildWorkChanged: args.onChildWorkChanged } : {}) }) } @@ -144,17 +97,10 @@ export class StructuredAgentSessionStatusFeed { ) private readonly subscribers = new Map() private readonly published = new Map() - // Task progress must not sort and scan an unchanged conversation. Journal identity owns cleanup. - private readonly journalProjections = new WeakMap< - AgentSessionJournal, - { - epoch: string - sequence: number - readOnly: boolean - fence: number | undefined - state: StructuredAgentSessionStatusState - } - >() + /** The user's newest accepted send each session was last projected with; a new one retires + * settled children. */ + private readonly acceptedSends = new Map() + private readonly projections = new StructuredAgentSessionJournalProjections() constructor(private readonly deps: StructuredAgentSessionStatusFeedDeps) {} @@ -171,19 +117,30 @@ export class StructuredAgentSessionStatusFeed { } /** The host stopped holding the session: ownership leaves the retained projection, and the - * row leaves the sink. `published` keeps the projection for reload history. */ + * row, with every child record under it, leaves the sink. `published` keeps the projection for + * reload history. */ close(sessionId: string): void { this.revokeLive(sessionId) this.forget(sessionId) } - /** The sink lists what is running; a forgotten session must not be in it. */ + /** The sink lists what is running; a forgotten session must not be in it. Its child records + * leave the store with its row, and the retained projection re-reads them like any summary. */ forget(sessionId: string): void { + this.acceptedSends.delete(sessionId) try { this.ownership.forget(sessionId) } catch (error) { console.warn('[structured-session-status] status sink forget failed', error) } + const previous = this.published.get(sessionId) + if (!previous || (!previous.children && !previous.backgroundTasks)) { + return + } + const { children: _children, backgroundTasks: _backgroundTasks, ...rest } = previous + const retained = { ...rest, ...this.childWorkFields(sessionId, previous.agent) } + this.published.set(sessionId, retained) + this.broadcast({ type: 'status', session: retained }) } unsubscribe(id: string): void { @@ -227,7 +184,7 @@ export class StructuredAgentSessionStatusFeed { ): StructuredAgentSessionStatusState | null { const session = this.deps.sessions.get(sessionId) const source = journal ?? session?.journal - return source ? this.projectionFor(source, this.deps.getRecord(sessionId)) : null + return source ? this.projections.read(source, this.deps.getRecord(sessionId)).state : null } /** Re-projects one session after its journal changed; equal projections are not re-sent. */ @@ -236,9 +193,13 @@ export class StructuredAgentSessionStatusFeed { if (!session) { return } - const summary = this.summaryFor(sessionId, session, journal ?? session.journal) + const source = journal ?? session.journal + const record = this.deps.getRecord(sessionId) + const projection = this.projections.read(source, record) + this.retireSettledChildrenOnNewTurn(sessionId, session, projection.acceptedSendKey) + const summary = this.summaryFor(sessionId, session, source, record, projection.state) const previous = this.published.get(sessionId) - if (previous && summariesEqual(previous, summary)) { + if (previous && structuredStatusSummariesEqual(previous, summary)) { if (!this.ownership.matchesLocation(sessionId, session.params.location)) { this.sink(summary, session.params.location) } @@ -258,23 +219,44 @@ export class StructuredAgentSessionStatusFeed { } } + /** A finished child's record stays, with its outcome, until the user's next turn: the next send + * the provider accepts (see `newestAcceptedSendKey`), unless it still owns live work. No surface + * lists it (they list running children only); it stays so a late frame of its run cannot bring + * it back as live, so an acknowledged Stop's provisional ending can be replaced by the task's + * own, and so its live work keeps its owner. The provider ending the session removes nothing: + * children still live settle `unknown`. The one earlier death is the host letting go of the + * session, whose row takes every child record with it (see `forget`). A command never settles: + * its record goes when it stops. */ + private retireSettledChildrenOnNewTurn( + sessionId: string, + session: StatusFeedSession, + acceptedSendKey: string | null + ): void { + // An unreadable journal says nothing about the user's turns: the last send read stands. + if (acceptedSendKey === null) { + return + } + const seen = this.acceptedSends.has(sessionId) + const previous = this.acceptedSends.get(sessionId) + this.acceptedSends.set(sessionId, acceptedSendKey) + if (!seen || acceptedSendKey === previous) { + return + } + this.admitChildWork(sessionId, session, [{ type: 'turn-started', observedAt: this.deps.now() }]) + } + private summaryFor( sessionId: string, session: StatusFeedSession, - journal: AgentSessionJournal + journal: AgentSessionJournal, + record: AgentSessionRecord | null, + state: StructuredAgentSessionStatusState ): AgentSessionStatusSummary { - const record = this.deps.getRecord(sessionId) - const { summary: projected } = this.projectionFor(journal, record) + const projected = state.summary const providerSession = structuredAgentSessionProviderSessionMetadata(record) // The journal has no model: the record's acknowledged options are where a mid-session // switch lands, so the row follows whichever is in force. const model = normalizeOptionalField(record?.options?.model, AGENT_MODEL_MAX_LENGTH) - // Usage is dropped here on purpose: a `task_progress` tick would otherwise fail the - // equality check and re-broadcast a full summary to every remote subscriber for a - // number no session list renders. Tokens stay live on the background-task channel. - const backgroundTasks = this.deps - .readBackgroundTasks?.(sessionId) - ?.tasks?.map(({ totalTokens: _totalTokens, ...task }) => task) return { sessionId, workspaceId: session.params.location.workspaceId, @@ -291,58 +273,70 @@ export class StructuredAgentSessionStatusFeed { ? { rewindBlockedReason: 'outcome-unknown' as const } : {}), ...(model ? { model } : {}), - ...(backgroundTasks && backgroundTasks.length > 0 ? { backgroundTasks } : {}), + ...this.childWorkFields(sessionId, session.params.provider), ...(providerSession ? { providerSession } : {}), updatedAt: journal.lastActivityAt() || this.deps.now() } } - /** Child-work evidence for a session this feed publishes; a failing sink costs nothing else. */ + /** The summary's child fields, from the records the store holds for the session. Usage is + * dropped on purpose: a `task_progress` tick would otherwise fail the equality check and + * re-broadcast a full summary to every remote subscriber for a number no session list renders. + * Tokens stay live on the background-task channel. */ + private childWorkFields( + sessionId: string, + provider: StatusFeedSession['params']['provider'] + ): Pick { + const { children, backgroundTasks } = structuredStatusChildWork( + this.readChildWork(sessionId), + provider + ) + return { + ...(backgroundTasks && backgroundTasks.length > 0 + ? { backgroundTasks: backgroundTasks.map(({ totalTokens: _tokens, ...task }) => task) } + : {}), + ...(children ? { children } : {}) + } + } + + /** Child-work evidence for a session this feed publishes; a failing sink costs nothing else. + * The summary then re-reads the records, which is also what re-folds the parent's row. */ publishChildWork(sessionId: string, evidence: AgentChildWorkEvidence[]): void { const session = this.deps.sessions.get(sessionId) - if (!session) { + if (!session || !this.admitChildWork(sessionId, session, evidence)) { return } + this.publish(sessionId) + } + + /** The session's child records as views, once its parent row has landed; the one read every + * surface of them shares. */ + readChildWork(sessionId: string): AgentChildWorkView[] | undefined { try { - this.ownership.publishChildWork(sessionId, evidence, session.params.provider) + return this.ownership.readChildWork(sessionId) } catch (error) { - console.warn('[structured-session-status] child work publish failed', error) + console.warn('[structured-session-status] child work read failed', error) + return undefined } } - private projectionFor( - journal: AgentSessionJournal, - record: AgentSessionRecord | null - ): StructuredAgentSessionStatusState { - // An unreadable journal projects as "no turn": the chat itself shows the reset. - const cursor = journal.cursor() - const readOnly = journal.isReadOnly - // The conversation's fence, which a child's end moves: its unanswered sends stop counting. - const fence = record?.lease.runtimeFence - let projection = this.journalProjections.get(journal) - if ( - !projection || - projection.epoch !== cursor.epoch || - projection.sequence !== cursor.sequence || - projection.readOnly !== readOnly || - projection.fence !== fence - ) { - // A journalled submission bumps `lastSequence`, so the send-time working - // signal reaches the cache; the lease fence does not, hence the extra key. - const snapshot = readOnly ? null : journal.snapshot() - projection = { - ...cursor, - readOnly, - fence, - state: projectStructuredAgentSessionStatusState( - snapshot?.items ?? [], - snapshot?.submissions ?? [], - fence - ) - } - this.journalProjections.set(journal, projection) + private admitChildWork( + sessionId: string, + session: StatusFeedSession, + evidence: AgentChildWorkEvidence[] + ): boolean { + try { + this.ownership.publishChildWork(sessionId, evidence, session.params.provider) + } catch (error) { + console.warn('[structured-session-status] child work publish failed', error) + return false + } + try { + this.deps.onChildWorkChanged?.(sessionId) + } catch (error) { + console.warn('[structured-session-status] child work observer failed', error) } - return projection.state + return true } /** A failing sink must never cost the subscribers their status event. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts new file mode 100644 index 000000000000..04ce86d36bca --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts @@ -0,0 +1,67 @@ +// The status feed's per-journal projection, cached per commit: what a session's journal says its +// row is, and the user's newest send the provider accepted. + +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { newestAcceptedSendKey } from './structured-agent-session-status-child-work' + +export type StructuredAgentSessionStatusState = ReturnType< + typeof projectStructuredAgentSessionStatusState +> + +export type StructuredAgentSessionJournalProjection = { + epoch: string + sequence: number + readOnly: boolean + fence: number | undefined + state: StructuredAgentSessionStatusState + /** Null for an unreadable journal, which says nothing about the user's turns. */ + acceptedSendKey: string | null +} + +export class StructuredAgentSessionJournalProjections { + // Task progress must not sort and scan an unchanged conversation. Journal identity owns cleanup. + private readonly byJournal = new WeakMap< + AgentSessionJournal, + StructuredAgentSessionJournalProjection + >() + + read( + journal: AgentSessionJournal, + record: AgentSessionRecord | null + ): StructuredAgentSessionJournalProjection { + // An unreadable journal projects as "no turn": the chat itself shows the reset. + const cursor = journal.cursor() + const readOnly = journal.isReadOnly + // The conversation's fence, which a child's end moves: its unanswered sends stop counting. + const fence = record?.lease.runtimeFence + let projection = this.byJournal.get(journal) + if ( + !projection || + projection.epoch !== cursor.epoch || + projection.sequence !== cursor.sequence || + projection.readOnly !== readOnly || + projection.fence !== fence + ) { + // A journalled submission bumps `lastSequence`, so the send-time working + // signal reaches the cache; the lease fence does not, hence the extra key. + const snapshot = readOnly ? null : journal.snapshot() + projection = { + ...cursor, + readOnly, + fence, + state: projectStructuredAgentSessionStatusState( + snapshot?.items ?? [], + snapshot?.submissions ?? [], + fence + ), + acceptedSendKey: snapshot + ? newestAcceptedSendKey(cursor.epoch, snapshot.submissions ?? []) + : null + } + this.byJournal.set(journal, projection) + } + return projection + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts index e7a270d10c4a..faf9022f586f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts @@ -4,6 +4,7 @@ import type { } from '../../../shared/agent-session-record' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { parseAgentStatusSubject, serializeAgentStatusSubject, @@ -22,6 +23,8 @@ export type StructuredAgentSessionStatusSink = { evidence: AgentChildWorkEvidence[], provider: AgentSessionRecord['provider'] ) => void + /** The child records the sink holds for that subject, as the views every surface reads. */ + readChildWork?: (subject: AgentStatusStructuredSessionSubject) => AgentChildWorkView[] } /** Retain the owner address because record removal may precede the final status callback. */ @@ -86,6 +89,12 @@ export class StructuredAgentSessionStatusOwnership { } } + /** Undefined until the parent row has landed: a sink holds no children for a parent it lacks. */ + readChildWork(sessionId: string): AgentChildWorkView[] | undefined { + const subject = this.subjects.get(sessionId) + return subject && this.landed.has(sessionId) ? this.sink()?.readChildWork?.(subject) : undefined + } + forget(sessionId: string): void { const subject = this.subjects.get(sessionId) if (!subject) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts index 091e1f70feb9..fff0ae2383b8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { AgentHookServer } from '../../agent-hooks/server' @@ -52,7 +53,7 @@ async function createFeed() { string, { journal: AgentSessionJournal - params: { location: AgentSessionExecutionLocation; provider: 'codex' } + params: { location: AgentSessionExecutionLocation; provider: AgentSessionHandleProvider } } >([[SESSION, session]]) const server = new AgentHookServer() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts new file mode 100644 index 000000000000..739d9156c2dc --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts @@ -0,0 +1,57 @@ +// Which status-summary changes reach every session list. The summary is broadcast to remote +// subscribers too, so only a change a session list shows may re-send it. + +import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume' +import { + agentSessionBackgroundTasksEqual, + type AgentSessionStatusSummary +} from '../../../shared/agent-session-wire' +import { isAgentStatusHeldOpenByChildWork } from '../../../shared/agent-lead-status-fold' +import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' +import { structuredStatusChildrenEqual } from './structured-agent-session-status-child-work' + +export function structuredStatusSummariesEqual( + a: AgentSessionStatusSummary, + b: AgentSessionStatusSummary +): boolean { + return ( + a.workspaceId === b.workspaceId && + a.agent === b.agent && + a.status === b.status && + a.hostExecutionOwned === b.hostExecutionOwned && + a.hostExecutionPhase === b.hostExecutionPhase && + a.hostExecutionChild?.generation === b.hostExecutionChild?.generation && + a.hostExecutionChild?.fence === b.hostExecutionChild?.fence && + a.rewindBlockedReason === b.rewindBlockedReason && + // A moved state clock changes ranking; row activity alone, including a subagent's, does not. + // An idle state the journal cannot date still republishes, since readers date it by `updatedAt`, + // and so does one live child work holds open: readers take each publish as its evidence. + a.statusStartedAt === b.statusStartedAt && + (a.status !== 'idle' || + a.updatedAt === b.updatedAt || + (a.statusStartedAt !== undefined && !isIdleHeldOpenByChildWork(b))) && + a.latestPrompt === b.latestPrompt && + a.model === b.model && + a.toolName === b.toolName && + a.toolInput === b.toolInput && + a.lastAssistantMessage === b.lastAssistantMessage && + a.turnOutcome === b.turnOutcome && + agentSessionBackgroundTasksEqual(a.backgroundTasks, b.backgroundTasks) && + structuredStatusChildrenEqual(a.children, b.children) && + agentProviderSessionsEqual(undefined, a.providerSession, b.providerSession) + ) +} + +/** Held open by the host's child records, read the way every row reads them. */ +function isIdleHeldOpenByChildWork(summary: AgentSessionStatusSummary): boolean { + return ( + summary.status === 'idle' && + isAgentStatusHeldOpenByChildWork( + structuredAgentSessionAgentStatus({ + status: summary.status, + childWork: summary.children ?? summary.backgroundTasks, + turnOutcome: summary.turnOutcome + }) + ) + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts new file mode 100644 index 000000000000..8b3078df8dfb --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts @@ -0,0 +1,15 @@ +/** Why a provider child ended. In memory only: never journaled, persisted or sent. */ +export type StructuredAgentSessionChildEndCause = + | 'user-stop' + /** The user closed this chat: its tab, its launch, or a `/clear` that replaces it. */ + | 'user-close' + | 'host-stop' + | 'exit' + | 'attach-failed' + | 'evict' + +/** Why the host asked a child to stop. The adapter carries it onto the `ended` it settles with. */ +export type StructuredAgentSessionStopCause = Extract< + StructuredAgentSessionChildEndCause, + 'user-stop' | 'user-close' | 'host-stop' | 'evict' +> diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts index 20b5038f1734..dafe54e17901 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts @@ -10,10 +10,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - AgentSessionBackgroundTask, - AgentSessionStatusEvent -} from '../../../shared/agent-session-wire' +import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' import { AGENT_STATUS_STALE_AFTER_MS } from '../../../shared/agent-status-types' import { projectStructuredAgentSessionStatusSummary } from '../../../shared/structured-agent-session-projection' import { AgentHookServer, _internals } from '../../agent-hooks/server' @@ -60,8 +57,7 @@ async function openSession() { now: tick, stateDirectory: join(root, SESSION) }) - // The roster the provider adapter reports, and the host's status row the feed writes into. - const roster: { tasks: AgentSessionBackgroundTask[] } = { tasks: [] } + // The host's status row and child records, which the feed writes into and reads back. const server = new AgentHookServer() const feed = new StructuredAgentSessionStatusFeed({ sessions: new Map([ @@ -75,10 +71,12 @@ async function openSession() { ]), getRecord: () => null, now: () => 1, - readBackgroundTasks: () => ({ state: 'monitoring', tasks: roster.tasks }), statusSink: () => ({ publish: (summary, subject) => server.ingestStructuredStatus(summary, subject), - forget: (subject) => server.dropStructuredStatus(subject) + forget: (subject) => server.dropStructuredStatus(subject), + publishChildWork: (subject, evidence, provider) => + server.ingestStructuredChildWork(subject, evidence, provider), + readChildWork: (subject) => server.getStructuredChildWorkViews(subject) }) }) const events: AgentSessionStatusEvent[] = [] @@ -109,7 +107,7 @@ async function openSession() { } return { journal, - roster, + feed, server, projected, tick, @@ -279,7 +277,21 @@ describe("a subagent's work and the recency of the session that spawned it", () } }) ) - session.roster.tasks = [{ id: 'task-1', kind: 'agent', state: 'working' }] + // The spawn's frame lands in the journal before its child's record, as the host orders them. + await session.drain() + session.feed.publishChildWork(SESSION, [ + { + type: 'live', + observedAt: session.tick(), + child: { + handle: { idKind: 'task_id', id: 'task-1', runId: 'toolu_1' }, + kind: 'agent', + residency: 'background', + state: 'working', + stoppable: true + } + } + ]) handle(claudeResult('result-1')) await session.drain() const settled = session.latestStatus() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts index ae7956745646..2560ff1172d2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -206,6 +206,24 @@ export class AgentSessionSubscribers { } } + /** Re-sends the `/` surface to each subscriber whose view of it is out of date. */ + republishCommands(): void { + const hostNow = this.now() + for (const sessionId of this.bySession.keys()) { + for (const subscriber of this.subscribers(sessionId)) { + if ((this.hooks.readCommands?.(sessionId) ?? null) !== subscriber.commands) { + this.emit(subscriber, { + type: 'batch', + sessionId, + batch: emptyAgentSessionBatch(subscriber.cursor), + fence: subscriber.fence, + hostNow + }) + } + } + } + } + private subscribers(sessionId: string): Subscriber[] { return [...(this.bySession.get(sessionId)?.values() ?? [])] } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index a06c70bf30ad..871c05a6e337 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -132,7 +132,7 @@ function emitTurnLifecycle(state: 'running' | 'completed', ordinal: number): voi /** The sweep stops the child first and closes the conversation last. */ function waitForEviction(): Promise { return vi.waitFor(() => { - expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict') expect(host.hasSession(SESSION)).toBe(false) }) } @@ -245,9 +245,9 @@ describe('a chat that closes', () => { it('stops the provider child it started', async () => { await attach() - await host.close(SESSION) + await host.close(SESSION, 'evict') - expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict') expect(host.hasSession(SESSION)).toBe(false) expect(hostErrors).toEqual([]) // The record and its journal stay; only the process and the claim on it go. @@ -264,7 +264,7 @@ describe('a chat that closes', () => { it('answers a read from the pane that outlived it without starting a child', async () => { await attach() - await host.close(SESSION) + await host.close(SESSION, 'evict') expect(host.hasSession(SESSION)).toBe(false) expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true) @@ -297,7 +297,7 @@ describe('a chat that closes', () => { await vi.waitFor(() => expect(dispatch).toHaveBeenCalled()) const settlement = host.waitForSendSettlement(SESSION, result.value.clientMessageId) - await host.close(SESSION) + await host.close(SESSION, 'evict') // Eviction's settlement is a journal write, so the wait sees it rather than timing out. await expect(settlement).resolves.toMatchObject({ @@ -321,14 +321,14 @@ describe('a chat that closes', () => { // The child is stopped and the lease released before the handle closes; the entry is dropped // before that close, so a lost result leaves no closing handle for a reader to find. - await expect(host.close(SESSION)).rejects.toThrow('journal close result lost') + await expect(host.close(SESSION, 'evict')).rejects.toThrow('journal close result lost') expect(host.hasSession(SESSION)).toBe(false) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', ownerProcess: null }) - await expect(host.close(SESSION)).resolves.toBeUndefined() + await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() expect(closeSession).toHaveBeenCalledOnce() }) @@ -349,12 +349,12 @@ describe('a chat that closes', () => { }) const settled = captureSettledSubmissions() - await expect(host.close(SESSION)).rejects.toMatchObject({ step: 'drain-published' }) + await expect(host.close(SESSION, 'evict')).rejects.toMatchObject({ step: 'drain-published' }) // The child is proven gone, but the wind-down it owes is not done: nothing settled, no release. expect(session!.child).toBeNull() expect(store.getRecord(SESSION)?.lease.claimStatus).not.toBe('released') - await expect(host.close(SESSION)).resolves.toBeUndefined() + await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() expect(closeSession).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', @@ -462,7 +462,7 @@ describe('startup', () => { describe('a session closed and started again', () => { it('publishes provider events to the reattached chat', async () => { await attach() - await host.close(SESSION) + await host.close(SESSION, 'evict') expect(host.hasSession(SESSION)).toBe(false) await startAgent() @@ -820,7 +820,7 @@ describe('a quit over an eviction that never got its retry', () => { const settled = captureSettledSubmissions() failNextDrain() - await expect(host.close(SESSION)).rejects.toMatchObject({ step: 'drain-published' }) + await expect(host.close(SESSION, 'evict')).rejects.toMatchObject({ step: 'drain-published' }) expect(host['sessions'].get(SESSION)?.child).toBeNull() expect(store.getRecord(SESSION)?.lease.claimStatus).not.toBe('released') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts index ca9f64c05731..a90a65c1c831 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts @@ -49,7 +49,7 @@ type RequestMark = Pick type SessionBaseline = CompletionFeedCursor & { settled: RequestMark | null } function settledMark(request: StructuredAgentSessionLatestRequest | null): RequestMark | null { - return request && !request.running ? { kind: request.kind, id: request.id } : null + return request && request.turnState !== 'running' ? { kind: request.kind, id: request.id } : null } export class StructuredAgentSessionTurnCompletionFeed { @@ -115,7 +115,7 @@ export class StructuredAgentSessionTurnCompletionFeed { return } baseline.sequence = cursor.sequence - if (request?.running) { + if (request?.turnState === 'running') { // A running turn clears the mark, so this detector fires on each running → settled // transition rather than on an id it happens not to have seen. baseline.settled = null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index eb3784b0a1d7..cdb70ff41d45 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -1,4 +1,6 @@ +import { agentChildWorkStopTargets } from '../../../shared/agent-child-work-stop-targets' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import type { AgentJournalStatusItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionCancelResult } from '../../../shared/agent-session-wire' @@ -30,6 +32,33 @@ export async function isMainAgentWorkingOnceFlushed( ) } +/** What a Stop that ends the provider's session leaves its next serialized step: whether the + * provider took the interrupt, so its wind-down is worth waiting on, and when the interrupt went + * out. No turn id: that step runs right behind the Stop, so no later turn can slip in between. */ +export type StructuredAgentSessionStopWindDown = { waitsForProvider: boolean; stoppedAt: number } + +/** + * A session-ending Stop's second step, queued behind its first in the same tick so nothing sent + * meanwhile reaches the child it ends. The Stop has answered: a failure here is reported. The next + * Stop retries the wind-down it leaves owed, and so does the idle sweep: at its next tick once the + * child is proven gone, else only after the chat idles with no child work left. + */ +export async function endStoppedStructuredAgentSession( + ctx: Pick, + windDown: StructuredAgentSessionStopWindDown, + stopChild: () => Promise, + onError: (error: unknown) => void +): Promise { + try { + if (windDown.waitsForProvider) { + await ctx.adapter.awaitStoppedRequestEnd?.(ctx.sessionId, windDown.stoppedAt) + } + await stopChild() + } catch (error) { + onError(error) + } +} + export async function performCancel( ctx: AgentSessionTurnContext, input: { @@ -41,8 +70,13 @@ export async function performCancel( prompt?: { itemId: string; expectedRevision: number } /** Ends the provider child, for a running command the provider did not take the Stop on. */ stopChild?: () => Promise + /** Hands the child's end to the Stop's next serialized step, for a provider whose Stop ends + * its session. */ + endSession?: (windDown: StructuredAgentSessionStopWindDown) => void /** The host already withdrew queued messages for this Stop. */ withdrewQueued?: boolean + /** The session's child records: a background Stop reaches the tasks they offer a stop. */ + childWork?: () => readonly AgentChildWorkView[] | undefined } ): Promise> { if (input.prompt) { @@ -66,6 +100,12 @@ export async function performCancel( (input.turnId === undefined || input.turnId === liveTurnId) const stoppedBefore = runningCommand && structuredAgentSessionCommandWasStopped(ctx.journal, liveTurnId) + // Read while the child is live: a provider whose Stop is a session boundary loses it next. + const endsSession = + input.endSession !== undefined && ctx.adapter.stopEndsSession?.(ctx.sessionId) === true + const stoppedAt = Date.now() + // The provider's own answer; unset when its cancel threw, leaving the effect unknown. + let taken: boolean | undefined try { const dispatchStatus = latestJournalDispatchObservation(ctx.journal, ctx.fence) const outcome: AgentSessionCancelOutcome = stoppedBefore @@ -77,7 +117,7 @@ export async function performCancel( await ctx.adapter.stopBackgroundTasks?.({ sessionId: ctx.sessionId, fence: ctx.fence, - ...(input.taskId ? { taskId: input.taskId } : {}) + taskIds: agentChildWorkStopTargets(input.childWork?.(), input.taskId) }) )?.cancelled === true } @@ -90,6 +130,7 @@ export async function performCancel( ...(dispatchStatus ? { dispatchStatus } : {}), ...(input.prompt ? { prompt: { itemId: input.prompt.itemId } } : {}) }) + taken = outcome.cancelled cancelled = outcome.cancelled if (!cancelled && input.withdrewQueued && !(await isMainAgentWorkingOnceFlushed(ctx))) { // A Stop that withdrew what was queued and left nothing working ended what it was sent for, @@ -121,7 +162,21 @@ export async function performCancel( ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) } } - if (runningCommand && !cancelled) { + // A Stop naming a turn that has since ended keeps the session only when the provider declined + // it: an interrupt, answered or not, can stop a follow-up whose turn has not opened. + if ( + endsSession && + (input.turnId === undefined || input.turnId === liveTurnId || taken !== false) + ) { + // An interrupt the provider took is worth waiting on, turn row or not: a Stop before the echo + // has none, and the echo still opens the turn the Stop interrupted. + input.endSession?.({ waitsForProvider: taken === true, stoppedAt }) + cancelled = true + // The child's end confirms the Stop, so a refused or unconfirmed interrupt says nothing more. + if (note !== null) { + note = { kind: 'status', text: 'Cancellation requested.' } + } + } else if (runningCommand && !cancelled) { await input.stopChild?.() cancelled = true note = { kind: 'status', text: 'Cancellation requested.' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index 2a9ed84012b7..7c5ecf1ee15d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' @@ -196,14 +197,20 @@ describe('performCancel', () => { const result = await performCancel(ctx, { clientOperationId: 'cancel-background-tasks', turnId: 'background-tasks', - scope: 'background-tasks' + scope: 'background-tasks', + childWork: () => [liveTask('task-1'), liveTask('task-2', { stoppable: false })] }) expect(result).toEqual({ ok: true, value: { turnId: 'background-tasks', cancelled: true } }) - expect(stopBackgroundTasks).toHaveBeenCalledWith({ sessionId: 'session-1', fence: 1 }) + // Every task the records offer a stop, and nothing else. + expect(stopBackgroundTasks).toHaveBeenCalledWith({ + sessionId: 'session-1', + fence: 1, + taskIds: ['task-1'] + }) expect(cancelTurn).not.toHaveBeenCalled() expect(journal.snapshot().items).toEqual([]) }) @@ -229,7 +236,8 @@ describe('performCancel', () => { clientOperationId: 'cancel-background-task-2', turnId: 'background-tasks', scope: 'background-tasks', - taskId: 'task-2' + taskId: 'task-2', + childWork: () => [liveTask('task-1'), liveTask('task-2')] }) expect(result).toEqual({ @@ -239,13 +247,32 @@ describe('performCancel', () => { expect(stopBackgroundTasks).toHaveBeenCalledWith({ sessionId: 'session-1', fence: 1, - taskId: 'task-2' + taskIds: ['task-2'] }) expect(cancelTurn).not.toHaveBeenCalled() expect(journal.snapshot().items).toEqual([]) }) }) +/** A live child record the strip would offer a stop, named by the provider as `providerId`. */ +function liveTask( + providerId: string, + overrides: Partial = {} +): AgentChildWorkView { + return { + id: `child-${providerId}`, + providerId, + kind: 'agent', + state: 'working', + membership: 'live', + firstObservedAt: 1, + observedAt: 1, + stoppable: true, + invocation: { invocationId: `spawn-${providerId}`, generation: 1 }, + ...overrides + } +} + describe('what a conversation Stop reports when the provider stopped nothing', () => { async function cancelWith( outcome: Awaited>, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts index 713838c177ec..62078ef853c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts @@ -3,7 +3,7 @@ import type { StructuredAgentSessionHostDeps } from './structured-agent-session- import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' /** - * Releases sends the provider can no longer be holding. + * Releases doubted sends once the provider reports it has stopped running. * * A dispatch whose RPC timed out is recorded `unknown` — doubt, never proof of * non-delivery — and a live `unknown` reads as work still owed, so the session diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 3ae6da5fa493..2981815c1f17 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -353,10 +353,10 @@ describe('already-wedged profiles become usable on load', () => { // What the sidebar reads: every status this restart published says the chat is not working. expect(published.filter((summary) => summary.sessionId === SESSION)).not.toEqual([]) expect(published.map((summary) => summary.status)).not.toContain('working') - // A crash is not something the user did: no outcome is claimed, so no reader files it as a - // cancellation the user already knows about. + // A crash is not something the user did: a proven one reads as an interruption and an + // unprovable one as unconfirmed, so no reader files it as a cancellation the user knows about. expect(published.map((summary) => summary.turnOutcome)).toEqual( - published.map(() => undefined) + published.map(() => (verdict.state === 'interrupted' ? 'interruption' : 'unconfirmed')) ) } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts index c3186d00daf5..11647f2eefde 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts @@ -28,7 +28,6 @@ import type { AgentJournalRenderItem, AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import type { AgentSessionBackgroundTask } from '../../../shared/agent-session-background-task-wire' import { AGENT_SESSION_RESTART_ACTIVITY_MAX_LABEL_LENGTH, AGENT_SESSION_RESTART_ACTIVITY_MAX_PROMPTS, @@ -38,6 +37,7 @@ import { type AgentSessionRestartTask } from '../../../shared/agent-session-restart-activity' import { isLiveChildWork } from '../../../shared/agent-status-child-work-liveness' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { @@ -148,14 +148,14 @@ function pendingPrompts(items: readonly AgentJournalRenderItem[]): AgentSessionR return prompts } -/** The live rows of the provider's roster, by the same liveness rule the sidebar's fold counts. */ +/** The live child records, by the same liveness rule the sidebar's fold counts. */ function liveTasks( - roster: readonly AgentSessionBackgroundTask[] | null | undefined + childWork: readonly AgentChildWorkView[] | undefined ): AgentSessionRestartTask[] { - return (roster ?? []) - .filter((task) => isLiveChildWork(task)) + return (childWork ?? []) + .filter((child) => isLiveChildWork(child)) .slice(0, AGENT_SESSION_RESTART_ACTIVITY_MAX_TASKS) - .map((task) => ({ kind: task.kind, label: boundedLabel(task.description ?? task.name) })) + .map((child) => ({ kind: child.kind, label: boundedLabel(child.description ?? child.name) })) } type WorkingCandidateSession = { @@ -170,8 +170,8 @@ export function structuredAgentSessionWorkingAtStop(input: { sessionId: string session: WorkingCandidateSession | undefined getRecord: (sessionId: string) => AgentSessionRecord | null - /** The provider's live child roster, the same one the status feed publishes. */ - backgroundTasks: (sessionId: string) => readonly AgentSessionBackgroundTask[] | null | undefined + /** The session's child records, the same read the status feed publishes. */ + childWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined trigger: AgentSessionResumeTrigger /** Stable teardown identity for continuation deduplication, not launch ancestry. */ teardownId: string @@ -187,10 +187,10 @@ export function structuredAgentSessionWorkingAtStop(input: { const handedOver = snapshot.submissions.filter( (submission) => !isQueuedAgentJournalSubmission(submission) ) - const roster = input.backgroundTasks(sessionId) + const childWork = input.childWork(sessionId) const status = structuredAgentSessionShownStatus( { items: snapshot.items, submissions: handedOver }, - roster, + childWork, session.child.fence ) if (status.state === 'done') { @@ -208,7 +208,7 @@ export function structuredAgentSessionWorkingAtStop(input: { // and carries them in `tasks`, which is how the dialog tells the two apart. state: status.mainAgent.state, prompts: pendingPrompts(snapshot.items), - tasks: liveTasks(roster) + tasks: liveTasks(childWork) } return { sessionId, diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts index 0acdb2dbbcea..5c2ff3662437 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts @@ -1,56 +1,111 @@ import { describe, expect, it } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionBackgroundTaskState } from '../../../shared/agent-session-wire' -import { conversationCommandBlocked } from './structured-conversation-command-admission' -import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import { + conversationCommandBlocked, + type ConversationCommandAdmissionContext +} from './structured-conversation-command-admission' +import type { AgentSessionBackgroundTaskStops } from '../../../shared/agent-child-work-stop-targets' + +const TARGETED: AgentSessionBackgroundTaskStops = { supportsTaskStop: true, supportsStopAll: true } +const UNTARGETED: AgentSessionBackgroundTaskStops = { + supportsTaskStop: false, + supportsStopAll: true +} +const NO_STOP: AgentSessionBackgroundTaskStops = { supportsTaskStop: false, supportsStopAll: false } function contextWith( - backgroundTasks: AgentSessionBackgroundTaskState | null -): AgentSessionTurnContext { + stops: AgentSessionBackgroundTaskStops | undefined +): ConversationCommandAdmissionContext { + const adapter = { + backgroundTaskStops: () => stops, + // The provider tracker's roster, claiming live work: admission must not consult it. + backgroundTaskState: () => ({ state: 'monitoring', tasks: [{ id: 'tracker-only' }] }) + } return { sessionId: 'session-1', - journal: { - snapshot: () => ({ items: [] }), - submissions: () => [] - }, - adapter: { backgroundTaskState: () => backgroundTasks } - } as unknown as AgentSessionTurnContext + fence: 1, + journal: { snapshot: () => ({ items: [] }), submissions: () => [] }, + adapter + } +} + +function child(overrides: Partial = {}): AgentChildWorkView { + return { + id: 'child-1', + providerId: 'task-1', + kind: 'command', + description: 'npm run dev', + state: 'working', + membership: 'live', + firstObservedAt: 100, + observedAt: 200, + stoppable: true, + invocation: { invocationId: 'spawn-1', generation: 1 }, + ...overrides + } } +// A finished subagent: a command's record goes when it stops, so only a child that settles lists. +const SETTLED = child({ + id: 'child-0', + kind: 'agent', + description: 'review', + state: 'done', + membership: 'settled', + outcome: 'succeeded', + settledAt: 300 +}) const RECORD = { lease: {} } as unknown as AgentSessionRecord +const STOP = 'Stop background tasks before using this command.' +const WAIT = 'Wait for background tasks to finish before using this command.' -describe('conversationCommandBlocked background tasks', () => { - it('admits the command when nothing is being monitored', () => { - expect(conversationCommandBlocked(contextWith(null), RECORD)).toBeNull() +describe('conversationCommandBlocked background work', () => { + it('admits the command when the strip lists nothing, whatever the provider tracker holds', () => { + expect(conversationCommandBlocked(contextWith(TARGETED), RECORD, [])).toBeNull() + // The parent row has not landed, so the strip has no records to show either. + expect(conversationCommandBlocked(contextWith(TARGETED), RECORD, undefined)).toBeNull() }) - it('asks for a stop when the host accepts targeted stops', () => { - const blocked = conversationCommandBlocked( - contextWith({ state: 'monitoring', supportsTaskStop: true }), - RECORD - ) - expect(blocked?.message).toBe('Stop background tasks before using this command.') + it('admits the command when the strip lists only finished children', () => { + expect(conversationCommandBlocked(contextWith(TARGETED), RECORD, [SETTLED])).toBeNull() + }) + + it('asks for a stop when the strip offers one on a live row', () => { + expect( + conversationCommandBlocked(contextWith(TARGETED), RECORD, [SETTLED, child()])?.message + ).toBe(STOP) + }) + + it('asks for a stop when the strip offers its single untargeted stop', () => { + const live = child({ stoppable: false }) + expect(conversationCommandBlocked(contextWith(UNTARGETED), RECORD, [live])?.message).toBe(STOP) }) - it('asks for a stop on a host that predates the stop-capability field', () => { - const blocked = conversationCommandBlocked(contextWith({ state: 'monitoring' }), RECORD) - expect(blocked?.message).toBe('Stop background tasks before using this command.') + it('asks the user to wait when no live row on the strip has a stop', () => { + // A targeted-stop provider renders no untargeted stop, so these rows offer nothing. + const unstoppable = child({ stoppable: false }) + const unaddressable = child({ id: 'child-2', providerId: undefined }) + expect( + conversationCommandBlocked(contextWith(TARGETED), RECORD, [unstoppable, unaddressable]) + ?.message + ).toBe(WAIT) }) it('asks the user to wait when the provider exposes no stop at all', () => { // Codex: an instruction to stop would name a control that does not exist. - const blocked = conversationCommandBlocked( - contextWith({ state: 'monitoring', supportsStopAll: false }), - RECORD + expect(conversationCommandBlocked(contextWith(NO_STOP), RECORD, [child()])?.message).toBe(WAIT) + // No live provider session answers for it either. + expect(conversationCommandBlocked(contextWith(undefined), RECORD, [child()])?.message).toBe( + WAIT ) - expect(blocked?.message).toBe('Wait for background tasks to finish before using this command.') }) it('still refuses on the open turn, not on the work the strip now shows', () => { // The strip reports subagents while a turn runs. That must not change which // refusal the user sees: an open turn already refuses, and it refuses first, // so a live fan-out never re-labels the reason or blocks anything new. - const ctx = contextWith({ state: 'monitoring', supportsTaskStop: true }) + const ctx = contextWith(TARGETED) ctx.journal.snapshot = () => ({ items: [ @@ -63,7 +118,7 @@ describe('conversationCommandBlocked background tasks', () => { } ] }) as unknown as ReturnType - expect(conversationCommandBlocked(ctx, RECORD)?.message).toBe( + expect(conversationCommandBlocked(ctx, RECORD, [child()])?.message).toBe( 'Wait for the current turn to finish before using this command.' ) }) @@ -75,11 +130,11 @@ describe('conversationCommandBlocked for a command sent at rest (C6, B3)', () => } it("does not refuse over a dead generation's running turn, which the start sweeps", () => { - const ctx = contextWith(null) + const ctx = contextWith(undefined) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only each item's body. ctx.journal.snapshot = () => staleTurn as never - expect(conversationCommandBlocked(ctx, RECORD, 'at-rest')).toBeNull() - expect(conversationCommandBlocked(ctx, RECORD)).toMatchObject({ + expect(conversationCommandBlocked(ctx, RECORD, [], 'at-rest')).toBeNull() + expect(conversationCommandBlocked(ctx, RECORD, [])).toMatchObject({ details: { reason: 'turnActive' }, message: 'Wait for the current turn to finish before using this command.' }) @@ -91,7 +146,7 @@ describe('conversationCommandBlocked for a command sent at rest (C6, B3)', () => lease: {}, conversationCommand: { command: 'compact', phase: 'prepared', state: 'unknown' } } as unknown as AgentSessionRecord - expect(conversationCommandBlocked(contextWith(null), record)).toBeNull() + expect(conversationCommandBlocked(contextWith(undefined), record, [])).toBeNull() }) // A clear's commit is its only durable write, so a record short of it never changed the chat. @@ -106,7 +161,7 @@ describe('conversationCommandBlocked for a command sent at rest (C6, B3)', () => replacementSessionId: 'clear-replacement' } } as unknown as AgentSessionRecord - expect(conversationCommandBlocked(contextWith(null), record)).toBeNull() + expect(conversationCommandBlocked(contextWith(undefined), record, [])).toBeNull() }) it('refuses on a committed clear', () => { @@ -120,20 +175,20 @@ describe('conversationCommandBlocked for a command sent at rest (C6, B3)', () => replacementSessionId: 'clear-replacement' } } as unknown as AgentSessionRecord - expect(conversationCommandBlocked(contextWith(null), record)).toMatchObject({ + expect(conversationCommandBlocked(contextWith(undefined), record, [])).toMatchObject({ code: 'agent_session_operation_invalid', details: { reason: 'conversationCleared' } }) }) it('at handover, lets the command itself and messages queued behind it wait', () => { - const ctx = contextWith(null) + const ctx = contextWith(undefined) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only the dispatch fields. const queued = [ { clientMessageId: 'command', dispatchState: 'pending', handoverRecorded: true }, { clientMessageId: 'behind', dispatchState: 'pending', handoverRecorded: true } ] as never ctx.journal.submissions = () => queued - expect(conversationCommandBlocked(ctx, RECORD, 'handover')).toBeNull() + expect(conversationCommandBlocked(ctx, RECORD, [], 'handover')).toBeNull() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts index 937f63733c5e..86bf49946922 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts @@ -1,13 +1,31 @@ +import { + agentChildWorkViewOffersStop, + type AgentSessionBackgroundTaskStops +} from '../../../shared/agent-child-work-stop-targets' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-liveness' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' -import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { refuse, type AgentSessionRefusalReason, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire-refusals' +/** What admission reads of a turn: the journal, its fence, and only the provider's stop capability. */ +export type ConversationCommandAdmissionContext = { + sessionId: string + fence: number + journal: { + snapshot(): Pick, 'items'> + submissions: AgentSessionJournal['submissions'] + } + adapter: Pick +} + function blocked( reason: AgentSessionRefusalReason<'agent_session_operation_invalid'>, message: string @@ -22,13 +40,16 @@ export function conversationCommandInFlight(): AgentSessionWireRefusal { /** * Why a conversation command may not run now; null when it may. * + * `childWork` is the session's child records as the chat strip reads them: a refusal may only + * cite work the strip lists, and ask for a stop only when the strip offers one. * `at-rest`: a command accepted with no child running. A running turn on record then belongs to a * dead generation, which the start before handover sweeps, so it refuses nothing yet. * `handover`: the command is the oldest queued message, and those queued behind it wait for it. */ export function conversationCommandBlocked( - ctx: Pick, + ctx: ConversationCommandAdmissionContext, record: AgentSessionRecord, + childWork: readonly AgentChildWorkView[] | undefined, admission?: 'at-rest' | 'handover' ): AgentSessionWireRefusal | null { const items = ctx.journal.snapshot().items @@ -63,14 +84,11 @@ export function conversationCommandBlocked( 'Resolve the pending question or approval before using this command.' ) } - const backgroundTasks = ctx.adapter.backgroundTaskState?.(ctx.sessionId) - if (backgroundTasks?.state === 'monitoring') { - // Only ask for a stop the host can actually perform. A provider that - // exposes neither a targeted nor an untargeted stop would otherwise leave - // the command refused behind an instruction nobody can follow. + // The same liveness fold the strip's monitoring indicator reads: settled rows block nothing. + if (agentChildWorkLiveness(childWork) !== null) { return blocked( 'backgroundTasksRunning', - backgroundTasks.supportsTaskStop || backgroundTasks.supportsStopAll !== false + stripOffersStop(childWork ?? [], ctx.adapter.backgroundTaskStops?.(ctx.sessionId)) ? 'Stop background tasks before using this command.' : 'Wait for background tasks to finish before using this command.' ) @@ -91,3 +109,17 @@ export function conversationCommandBlocked( } return null } + +/** The strip's own stop controls: a per-row stop where the provider can target one, else its + * single untargeted stop. Asking for a stop it does not render names a control nobody can use. */ +function stripOffersStop( + childWork: readonly AgentChildWorkView[], + stops: AgentSessionBackgroundTaskStops | undefined +): boolean { + if (!stops) { + return false + } + return stops.supportsTaskStop + ? childWork.some(agentChildWorkViewOffersStop) + : stops.supportsStopAll +} diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-child-work.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-child-work.test.ts new file mode 100644 index 000000000000..c44236ce34ba --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-child-work.test.ts @@ -0,0 +1,180 @@ +// A conversation command is refused for background work only when the chat strip lists that work: +// both read the host's child records through the one sink read. The provider tracker's own roster +// is present and claims live work throughout; nothing here may be decided by it. + +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { AgentSessionBackgroundTaskState } from '../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { structuredAgentSessionCommandTurn } from './structured-agent-session-command-turn' +import { + attach, + CALLER, + envelope, + hostTestState, + serveHostTestChildWork +} from './structured-agent-session-host-test-harness' +import { HOST_TEST_NOW, HOST_TEST_SESSION } from './structured-agent-session-host-test-data' + +let state: ReturnType +let compact: Mock> +/** What the store holds for the session; the sink serves it to every reader. */ +let records: AgentChildWorkView[] = [] + +function compactParams() { + return { + command: 'compact' as const, + envelope: envelope('agentSession.conversationCommand', { command: 'compact' }) + } +} + +function devServer(overrides: Partial = {}): AgentChildWorkView { + return { + id: 'child-dev', + providerId: 'task-dev', + kind: 'command', + description: 'npm run dev', + state: 'working', + membership: 'live', + firstObservedAt: HOST_TEST_NOW, + observedAt: HOST_TEST_NOW, + stoppable: true, + invocation: { invocationId: 'spawn-dev', generation: 1 }, + ...overrides + } +} + +/** The provider ends the command's turn, as the child's journal translator does. */ +function finish(): void { + const { command } = compact.mock.calls.at(-1)![0] + const events = state.acquire.mock.calls.at(-1)![0].events! + events.appendLifecycleBatch!( + `turn-completed:${command.clientMessageId}`, + [ + { + kind: 'item', + identity: command.identity, + body: { ...command.running, state: 'completed', outcome: 'success' }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ], + { lifecycle: true } + ) +} + +async function commandTurnState(clientMessageId: string): Promise { + const { itemId } = structuredAgentSessionCommandTurn(clientMessageId) + const item = (await state.host.journalSnapshot(HOST_TEST_SESSION)).items.find( + (entry) => entry.itemId === itemId + ) + return item?.body.kind === 'turn' ? item.body.state : undefined +} + +beforeEach(() => { + records = [] + state = hostTestState() + compact = vi.fn(async () => ({ state: 'accepted' as const, providerIdentity: null })) + const trackerRoster: AgentSessionBackgroundTaskState = { + state: 'monitoring', + tasks: [{ id: 'tracker-only', kind: 'command', description: 'not on the strip' }] + } + Object.assign(state.host.deps.adapter, { + compact, + backgroundTaskStops: () => ({ supportsTaskStop: true, supportsStopAll: true }), + // A tracker that drifted from the records: it still claims work the strip does not list. + backgroundTaskState: () => trackerRoster + }) + serveHostTestChildWork(() => records) +}) + +describe('conversation command admission reads the strip’s child records', () => { + it('refuses only while the strip lists live work, and names the stop the strip offers', async () => { + await attach() + const strip: (AgentSessionBackgroundTaskState | null)[] = [] + await state.host.subscribe({ + id: 'strip', + sessionId: HOST_TEST_SESSION, + emit: (event) => { + if ('backgroundTasks' in event && event.backgroundTasks !== undefined) { + strip.push(event.backgroundTasks) + } + } + }) + const stripRows = () => + (strip.at(-1)?.children ?? []).map((row) => ({ + description: row.description, + membership: row.membership, + stoppable: row.stoppable, + providerId: row.providerId + })) + + // Nothing on the strip: the drifted tracker's roster refuses nothing. + expect(stripRows()).toEqual([]) + const first = compactParams() + expect(await state.host.conversationCommand(CALLER, first)).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(compact).toHaveBeenCalledTimes(1)) + finish() + await vi.waitFor(async () => + expect(await commandTurnState(first.envelope.clientOperationId)).toBe('completed') + ) + + records = [devServer()] + state.host.publishChildWorkEvidence(HOST_TEST_SESSION, []) + expect(stripRows()).toEqual([ + { description: 'npm run dev', membership: 'live', stoppable: true, providerId: 'task-dev' } + ]) + expect(await state.host.conversationCommand(CALLER, compactParams())).toMatchObject({ + ok: false, + refusal: { message: 'Stop background tasks before using this command.' } + }) + + records = [devServer({ state: 'done', membership: 'settled', outcome: 'succeeded' })] + state.host.publishChildWorkEvidence(HOST_TEST_SESSION, []) + // Finished: the strip hides (it lists running children only), and the record blocks nothing. + expect(strip.at(-1)).toBeNull() + expect(stripRows()).toEqual([]) + expect(await state.host.conversationCommand(CALLER, compactParams())).toMatchObject({ + ok: true + }) + await vi.waitFor(() => expect(compact).toHaveBeenCalledTimes(2)) + }) + + it('refuses a queued command at handover once the strip lists live work', async () => { + const acquireChild = state.acquire.getMockImplementation()! + // Each child names its generation, so the exit below ends exactly the one it names. + state.acquire.mockImplementation(async (input) => ({ + ...(await acquireChild(input)), + acquisitionGeneration: `generation-${input.fence}` + })) + await attach() + // The provider exits, so the next command is accepted at rest and waits for a new start. + const fence = state.store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence + await state.host.handleAdapterEvent({ + type: 'ended', + sessionId: HOST_TEST_SESSION, + fence, + acquisitionGeneration: `generation-${fence}`, + reason: 'provider exited', + cause: 'unexpected-exit' + }) + await vi.waitFor(() => expect(state.host['sessions'].get(HOST_TEST_SESSION)?.child).toBeNull()) + // The records gain live work while that start runs, after the command was accepted. + const start = state.acquire.getMockImplementation()! + state.acquire.mockImplementationOnce(async (input) => { + records = [devServer()] + return start(input) + }) + const params = compactParams() + expect(await state.host.conversationCommand(CALLER, params)).toMatchObject({ ok: true }) + + await vi.waitFor(async () => { + const submission = (await state.host.journalSnapshot(HOST_TEST_SESSION)).submissions.find( + (entry) => entry.clientMessageId === params.envelope.clientOperationId + ) + expect(submission?.dispatchState).toBe('rejected') + }) + expect(state.acquire).toHaveBeenCalledTimes(2) + expect(compact).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts index 4e4e42c46e44..fae3c944ee26 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts @@ -17,7 +17,7 @@ export class StructuredConversationCommandController { private readonly context: () => StructuredAgentSessionMutationContext, private readonly host: Pick< StructuredAgentSessionHost, - 'attach' | 'flushStreamedEvents' | 'waitForSendSettlement' + 'flushStreamedEvents' | 'waitForSendSettlement' > ) {} send = ( @@ -45,8 +45,8 @@ export class StructuredConversationCommandController { if (--entry.count === 0 && this.pending.get(params.envelope.sessionId) === entry) { this.pending.delete(params.envelope.sessionId) } - // A clear can settle with no journal commit (a failed attach), and drafts held behind - // its prepared phase would otherwise wait for an unrelated commit. + // A clear can settle with no journal commit (a refusal), and drafts held behind it + // would otherwise wait for an unrelated commit. this.context().wakeQueuedDrain?.(params.envelope.sessionId) } ) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index 89b07045b3d5..336e120d8baf 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -1,8 +1,8 @@ +import { createHash } from 'node:crypto' import { cp, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-session-host-authority' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionConversationCommand } from '../../../shared/agent-session-conversation-command' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' @@ -113,7 +113,11 @@ beforeEach(async () => { linkId: `link-${acquisitions}`, mintedAtFence: input.fence, observedAt: HOST_TEST_NOW, - origin: input.fence > 1 ? ('resumed' as const) : ('created' as const), + // A start with no thread to resume creates one, whatever its fence. + origin: + input.fence > 1 && input.identity.providerHandle.kind === 'codex' + ? ('resumed' as const) + : ('created' as const), handle: { provider: 'codex' as const, threadId: @@ -147,141 +151,279 @@ afterEach(async () => { await rm(directory, { recursive: true, force: true }) }) -describe('host conversation commands', () => { - it('adopts the reported Fast preference into the replacement record', async () => { - adapter.readOptions = async () => ({ - models: [], - current: { model: 'test-model', effort: 'high', fastMode: false } - }) - const result = await host.conversationCommand(caller, commandParams('clear')) - expect(result.ok).toBe(true) - if (!result.ok) { - return +/** Every record but the first is one a committed /clear points at: nothing is left orphaned. */ +function expectEveryReplacementPointedAt(): void { + const records = store.listRecords() + const pointedAt = new Set( + records.map((record) => record.conversationCommand?.replacementSessionId) + ) + expect( + records.filter( + (record) => + record.sessionId !== HOST_TEST_SESSION && + !pointedAt.has(record.sessionId) && + !notFromAClear.has(record.sessionId) + ) + ).toEqual([]) +} +const notFromAClear = new Set() + +afterEach(() => { + expectEveryReplacementPointedAt() + notFromAClear.clear() +}) + +function startsFor(sessionId: string): number { + return vi + .mocked(adapter.acquire) + .mock.calls.filter(([input]) => input.identity.sessionId === sessionId).length +} + +function sendTo(sessionId: string, text: string) { + const body = hostTestMessage(text) + return host.send(caller, { + body, + envelope: { + sessionId, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(sessionId)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId, + fields: { body } + }) + } + }) +} + +async function clearCommits( + params = commandParams('clear'), + who: { callerKey: string } = caller +): Promise { + const result = await host.conversationCommand(who, params) + expect(result).toMatchObject({ + ok: true, + value: { + command: 'clear', + phase: 'committed', + state: 'completed', + replacementSessionId: expect.any(String) } - expect(store.getRecord(result.value.replacementSessionId!)).toMatchObject({ - options: { model: 'test-model', effort: 'high', fastMode: 'false' } - }) }) + expect(result.ok && 'error' in result.value).toBe(false) + return result.ok ? result.value.replacementSessionId! : '' +} + +/** The idle sweep stops the source's agent, leaving the chat at rest. */ +async function sourceAtRest(): Promise { + clock += STRUCTURED_AGENT_SESSION_IDLE_MS + 1 + await host.collaboratorsForTests().lifetime.idleSweep.tick() + expect(host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child ?? null).toBeNull() + expect(store.getRecord(HOST_TEST_SESSION)?.lease.claimStatus).toBe('released') +} + +async function submissionOf(sessionId: string, clientMessageId: string) { + return (await host.journalSnapshot(sessionId)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) +} + +async function errorRows(sessionId: string): Promise { + return (await host.journalSnapshot(sessionId)).items.flatMap((item) => + item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] + ) +} - // What the child reports can be a value it fell back to, such as a model whose restore write it - // never answered; the replacement's start replays the choice, as the source's next start would. - it('starts the replacement from the options the user chose, not the values the child reports', async () => { +describe('/clear starts nothing', () => { + it('commits the marker and an at-rest replacement founded from the source, and starts no agent', async () => { await store.replaceSessionOptions({ sessionId: HOST_TEST_SESSION, fence: store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence, options: { model: 'test-model', effort: 'low' }, now: HOST_TEST_NOW }) - adapter.readOptions = async () => ({ - models: [], - current: { model: 'fallback-model', effort: 'high' } + const source = store.getRecord(HOST_TEST_SESSION)! + const params = commandParams('clear') + const replacement = await clearCommits(params) + expect(adapter.acquire).toHaveBeenCalledTimes(1) + expect(replacement).toMatch(/^clear-[0-9a-f]{40}$/) + expect(store.getRecord(replacement)).toMatchObject({ + sessionId: replacement, + location: source.location, + provider: source.provider, + accountHome: source.accountHome, + options: { model: 'test-model', effort: 'low' }, + // Never started: nothing to resume, so its first start is a fresh conversation. + providerHandleChain: [], + lease: { + runtimeFence: 1, + claimStatus: 'released', + handoffStage: null, + ownerProcess: null, + reservedSpawnToken: null, + unreconciled: false, + claimKeyId: 'key' + } }) - const attach = vi.spyOn(host, 'attach') - expect(await host.conversationCommand(caller, commandParams('clear'))).toMatchObject({ - ok: true + // Written at the fence the old agent's stop released the lease at. + expect(store.getRecord(HOST_TEST_SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + runtimeFence: source.lease.runtimeFence + 1 }) - expect(attach.mock.calls[0]?.[1].options).toEqual({ model: 'test-model', effort: 'low' }) - expect(store.getRecord(HOST_TEST_SESSION)?.options).toEqual({ - model: 'test-model', - effort: 'low' + expect(store.getRecord(HOST_TEST_SESSION)?.conversationCommand).toEqual({ + command: 'clear', + phase: 'committed', + state: 'completed', + runtimeFence: source.lease.runtimeFence + 1, + operationId: params.envelope.clientOperationId, + callerKey: caller.callerKey, + replacementSessionId: replacement }) + expect(store.listVisibleSessionIds()).toEqual([replacement]) + expect(host.conversationReplacements().map((entry) => entry.sessionId)).toEqual([replacement]) + expect(host.collaboratorsForTests().sessions.get(replacement)?.child ?? null).toBeNull() + expect((await host.history({ sessionId: replacement, direction: 'tail' })).page.items).toEqual( + [] + ) + // The old conversation stays readable and takes no more messages. + expect(await host.send(caller, sendParams('into the old chat'))).toMatchObject({ + ok: false, + refusal: { details: { reason: 'conversationCleared' } } + }) + expect(adapter.dispatch).not.toHaveBeenCalled() }) - it('clears with a fresh record and effective options, retaining old history and idempotent mapping', async () => { - const before = store.getRecord(HOST_TEST_SESSION)! - const params = commandParams('clear') - const result = await host.conversationCommand(caller, params) - expect(result.ok).toBe(true) - if (!result.ok) { - return - } - const nextId = result.value.replacementSessionId! - expect(nextId).not.toBe(HOST_TEST_SESSION) - expect(store.getRecord(nextId)).toMatchObject({ - location: before.location, - accountHome: before.accountHome, - options: { model: 'test-model', effort: 'high' } - }) - expect(store.getRecord(HOST_TEST_SESSION)).not.toBeNull() - expect(store.listVisibleSessionIds()).toEqual([nextId]) - expect((await host.history({ sessionId: nextId, direction: 'tail' })).page.items).toEqual([]) - expect(await host.conversationCommand(caller, params)).toMatchObject({ - ok: true, - replayed: true, - value: { replacementSessionId: nextId } - }) - expect(acquisitions).toBe(2) - const body = hostTestMessage('late send') + it('copies the launch arguments the source was pinned to', async () => { + const pinned = 'session-pinned' expect( - await host.send(caller, { - body, - envelope: { - ...params.envelope, - clientOperationId: hostTestOperationId(), - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: HOST_TEST_SESSION, - fields: { body } - }) - } + await host.attach( + caller, + hostTestAttachParams(null, { + envelope: { + sessionId: pinned, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: null, + payloadFingerprint: '' + }, + launchArgs: ['--flag'] + }) + ) + ).toMatchObject({ ok: true }) + const clear = { ...commandParams('clear') } + clear.envelope = { + sessionId: pinned, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(pinned)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.conversationCommand', + sessionId: pinned, + fields: { command: 'clear' } }) - ).toMatchObject({ ok: false }) - expect(adapter.dispatch).not.toHaveBeenCalled() + } + notFromAClear.add(pinned) + const replacement = await clearCommits(clear) + expect(store.getRecord(replacement)?.launchArgs).toEqual(['--flag']) }) - it('leaves the source usable when replacement creation is definitely refused', async () => { - vi.spyOn(host, 'attach').mockResolvedValueOnce({ - ok: false, - refusal: { code: 'structured_agent_session_unsupported', message: 'Unavailable' } + it("does not start an at-rest source's agent", async () => { + await sourceAtRest() + const starts = vi.mocked(adapter.acquire).mock.calls.length + const replacement = await clearCommits() + expect(adapter.acquire).toHaveBeenCalledTimes(starts) + expect(startsFor(HOST_TEST_SESSION)).toBe(1) + expect(startsFor(replacement)).toBe(0) + }) + + it("stops a running source's agent before it writes the marker", async () => { + const commit = store.commitConversationClear + let atCommit: { child: unknown; claim: string | undefined } | undefined + vi.spyOn(store, 'commitConversationClear').mockImplementationOnce(async (clear) => { + atCommit = { + child: host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child ?? null, + claim: store.getRecord(HOST_TEST_SESSION)?.lease.claimStatus + } + return commit(clear) }) - expect(await host.conversationCommand(caller, commandParams('clear'))).toMatchObject({ - ok: true, - value: { - state: 'completed', - replacementSessionId: undefined, - // The refusal's message is Orca's log text; the result words its situation. - error: "Codex couldn't start. Start a new chat to continue.", - failure: { kind: 'startFailed', refusal: { code: 'structured_agent_session_unsupported' } } + await clearCommits() + expect(atCommit).toEqual({ child: null, claim: 'released' }) + }) + + it('founds one record per /clear through a chain of clears, starting neither', async () => { + const first = await clearCommits() + const second = await host.conversationCommand(caller, { + command: 'clear', + envelope: { + sessionId: first, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(first)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.conversationCommand', + sessionId: first, + fields: { command: 'clear' } + }) } }) + expect(second).toMatchObject({ ok: true, value: { replacementSessionId: expect.any(String) } }) + const last = second.ok ? second.value.replacementSessionId! : '' + expect(last).not.toBe(first) + expect(store.listRecords().map((record) => record.sessionId)).toEqual( + expect.arrayContaining([HOST_TEST_SESSION, first, last]) + ) + expect(store.listRecords()).toHaveLength(3) + expect(startsFor(first) + startsFor(last)).toBe(0) + expect(store.listVisibleSessionIds()).toEqual([last]) + expect(host.conversationReplacements()).toEqual([ + expect.objectContaining({ sourceSessionId: HOST_TEST_SESSION, sessionId: last }), + expect.objectContaining({ sourceSessionId: first, sessionId: last }) + ]) + }) + + it('writes nothing when its one write fails, leaving the source usable', async () => { + adapter.rewindSupport = () => ({ supported: true }) + vi.spyOn(store, 'commitConversationClear').mockRejectedValueOnce(new Error('disk full')) + await expect(host.conversationCommand(caller, commandParams('clear'))).rejects.toThrow( + 'disk full' + ) + expect(store.listRecords().map((record) => record.sessionId)).toEqual([HOST_TEST_SESSION]) + expect(store.getRecord(HOST_TEST_SESSION)?.conversationCommand).toBeUndefined() expect(store.listVisibleSessionIds()).toEqual([HOST_TEST_SESSION]) - expect(acquisitions).toBe(1) + // Past every conversation check: only the stale epoch it names stops it. + expect( + await host.rewind(caller, { + envelope: envelope('agentSession.rewind', { + itemId: 'item-1', + expectedEpoch: 'stale-epoch' + }), + itemId: 'item-1', + expectedEpoch: 'stale-epoch' + }) + ).toMatchObject({ ok: false, refusal: { rewindReason: 'stale-epoch' } }) expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ ok: true }) + expect(await host.send(caller, sendParams('still here'))).toMatchObject({ ok: true }) }) - it('tells the user to run /clear again when the replacement could not start', async () => { - vi.mocked(adapter.acquire).mockRejectedValueOnce(new Error('spawn codex ENOENT')) - expect(await host.conversationCommand(caller, commandParams('clear'))).toMatchObject({ - ok: true, - value: { state: 'completed', error: "Codex couldn't start. Run /clear again." } - }) + it('reruns under the same operation id after its write failed, and commits once', async () => { + vi.spyOn(store, 'commitConversationClear').mockRejectedValueOnce(new Error('disk full')) + const params = commandParams('clear') + await expect(host.conversationCommand(caller, params)).rejects.toThrow('disk full') + const replacement = await clearCommits(params) + expect(store.listRecords()).toHaveLength(2) + expect(store.listVisibleSessionIds()).toEqual([replacement]) }) - it('keeps the situation a refused replacement start named', async () => { - vi.mocked(adapter.acquire).mockRejectedValueOnce( - new AgentSessionAcquisitionRefusal('Codex is not signed in.', 'notSignedIn') - ) - expect(await host.conversationCommand(caller, commandParams('clear'))).toMatchObject({ + it('replays a same-id resend with the one replacement it committed', async () => { + const params = commandParams('clear') + const replacement = await clearCommits(params) + expect(await host.conversationCommand(caller, params)).toMatchObject({ ok: true, - value: { - state: 'completed', - replacementSessionId: undefined, - // The next step is the command the user ran, not a message into the old conversation. - error: 'Codex is not signed in for the selected account. Sign in, then run /clear again.', - failure: { kind: 'notSignedIn' } - } + replayed: true, + value: { replacementSessionId: replacement } }) - expect(store.listVisibleSessionIds()).toEqual([HOST_TEST_SESSION]) + expect(store.listRecords()).toHaveLength(2) }) - it('runs a command whose fence the client has not caught up to', async () => { - const params = commandParams('compact') - params.envelope.expectedRuntimeFence++ - expect(await host.conversationCommand(caller, params)).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(compact).toHaveBeenCalledTimes(1)) - }) it('reconstructs a committed replacement after the ledger settlement is lost', async () => { const persist = store.recordOperationOutcome.bind(store) vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { @@ -292,95 +434,176 @@ describe('host conversation commands', () => { }) const params = commandParams('clear') await expect(host.conversationCommand(caller, params)).rejects.toThrow('crash') + const [replacement] = store + .listRecords() + .flatMap((record) => (record.sessionId === HOST_TEST_SESSION ? [] : [record.sessionId])) expect(await host.conversationCommand(caller, params)).toMatchObject({ ok: true, replayed: true, - value: { state: 'completed' } + value: { state: 'completed', replacementSessionId: replacement } }) - expect(acquisitions).toBe(2) + expect(store.listRecords()).toHaveLength(2) + expect(acquisitions).toBe(1) + }) + + it("answers another window's /clear after the commit with the cleared refusal, never an in-flight one", async () => { + await clearCommits() + expect( + await host.conversationCommand({ callerKey: 'mobile' }, commandParams('clear')) + ).toMatchObject({ ok: false, refusal: { details: { reason: 'conversationCleared' } } }) + expect(store.listRecords()).toHaveLength(2) }) it('keeps explicitly revealed history and closed replacement tabs out of automatic restoration', async () => { - const result = await host.conversationCommand(caller, commandParams('clear')) - if (!result.ok) { - throw new Error('clear failed') - } + const replacement = await clearCommits() expect(host.conversationReplacements()).toHaveLength(1) await host.setSessionTabVisibility(HOST_TEST_SESSION, true) expect(host.conversationReplacements()).toEqual([]) await host.setSessionTabVisibility(HOST_TEST_SESSION, false) - await host.setSessionTabVisibility(result.value.replacementSessionId!, false) + await host.setSessionTabVisibility(replacement, false) expect(host.conversationReplacements()).toEqual([]) }) + + it('runs a command whose fence the client has not caught up to', async () => { + const params = commandParams('compact') + params.envelope.expectedRuntimeFence++ + expect(await host.conversationCommand(caller, params)).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(compact).toHaveBeenCalledTimes(1)) + }) }) -describe('a clear that never committed', () => { - /** The replacement's start answers with a refusal that proves nothing either way. */ - function refuseReplacementStartOnce() { - vi.spyOn(host, 'attach').mockResolvedValueOnce({ - ok: false, - refusal: { code: 'agent_session_operation_capacity', message: 'Too many operations.' } - }) - } +describe("the replacement's first send", () => { + it('starts a fresh conversation and delivers the message', async () => { + const replacement = await clearCommits() + const sent = await sendTo(replacement, 'first message') + expect(sent).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(adapter.dispatch).toHaveBeenCalledTimes(1)) + expect(startsFor(replacement)).toBe(1) + const [start] = vi + .mocked(adapter.acquire) + .mock.calls.flatMap(([input]) => (input.identity.sessionId === replacement ? [input] : [])) + // No provider conversation to resume: the source's thread is not carried over. + expect(start?.identity.providerHandle).toEqual({ + kind: 'opaque', + agent: 'codex', + value: 'pending' + }) + expect(start?.fence).toBe(2) + expect(store.getRecord(replacement)?.lease).toMatchObject({ claimStatus: 'live' }) + }) - async function clearCommits(params = commandParams('clear')) { - const result = await host.conversationCommand(caller, params) - expect(result).toMatchObject({ - ok: true, - value: { state: 'completed', replacementSessionId: expect.any(String) } + it('starts with the options the user chose, and adopts what the started child reports', async () => { + await store.replaceSessionOptions({ + sessionId: HOST_TEST_SESSION, + fence: store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence, + options: { model: 'test-model', effort: 'low' }, + now: HOST_TEST_NOW }) - return result.ok ? result.value.replacementSessionId! : '' - } - - it('refuses nothing afterwards: a rewind, a compaction and a send all run', async () => { - adapter.rewindSupport = () => ({ supported: true }) - refuseReplacementStartOnce() - await expect(host.conversationCommand(caller, commandParams('clear'))).rejects.toThrow( - 'Too many operations.' - ) - // Past every conversation check: only the stale epoch it names stops it. - expect( - await host.rewind(caller, { - envelope: envelope('agentSession.rewind', { - itemId: 'item-1', - expectedEpoch: 'stale-epoch' - }), - itemId: 'item-1', - expectedEpoch: 'stale-epoch' - }) - ).toMatchObject({ ok: false, refusal: { rewindReason: 'stale-epoch' } }) - expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ - ok: true + adapter.readOptions = async () => ({ + models: [], + current: { model: 'test-model', effort: 'low', fastMode: false } }) - expect(await host.send(caller, sendParams('still here'))).toMatchObject({ ok: true }) + const replacement = await clearCommits() + expect(await sendTo(replacement, 'first message')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(startsFor(replacement)).toBe(1)) + const [start] = vi + .mocked(adapter.acquire) + .mock.calls.flatMap(([input]) => (input.identity.sessionId === replacement ? [input] : [])) + expect(start?.options).toEqual({ model: 'test-model', effort: 'low' }) + await vi.waitFor(() => + expect(store.getRecord(replacement)?.options).toEqual({ + model: 'test-model', + effort: 'low', + fastMode: 'false' + }) + ) }) - it('lets a clear under a new operation id commit', async () => { - refuseReplacementStartOnce() - await expect(host.conversationCommand(caller, commandParams('clear'))).rejects.toThrow() + it('applies an option picked on the replacement before its first send', async () => { const replacement = await clearCommits() - expect(store.listVisibleSessionIds()).toEqual([replacement]) + const picked = await host.setOption(caller, { + envelope: { + sessionId: replacement, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(replacement)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.setOption', + sessionId: replacement, + fields: { key: 'model', value: 'picked-model' } + }) + }, + key: 'model', + value: 'picked-model' + }) + expect(picked).toMatchObject({ ok: true }) + expect(startsFor(replacement)).toBe(0) + expect(await sendTo(replacement, 'first message')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(startsFor(replacement)).toBe(1)) + const [start] = vi + .mocked(adapter.acquire) + .mock.calls.flatMap(([input]) => (input.identity.sessionId === replacement ? [input] : [])) + expect(start?.options).toMatchObject({ model: 'picked-model' }) }) - // Its id is too old to start anything now, and it started nothing to finish. - it('lets a clear under a new operation id commit a day after a try that started nothing', async () => { - refuseReplacementStartOnce() - await expect(host.conversationCommand(caller, commandParams('clear'))).rejects.toThrow() - clock += AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + 60_000 - const params = commandParams('clear') - params.envelope.clientOperationId = `${clock}-${'a'.repeat(32)}` - await clearCommits(params) - }) + it.each([ + { + name: 'not signed in', + error: () => new AgentSessionAcquisitionRefusal('Codex is not signed in.', 'notSignedIn'), + words: + 'Codex is not signed in for the selected account. Sign in, then send your message again.', + kind: 'notSignedIn' + }, + { + name: 'a generic refusal', + error: () => new Error('spawn codex ENOENT'), + words: "Codex couldn't start.", + kind: 'startFailed' + } + ])( + 'fails on the message when the start fails ($name): one row, the message kept as not sent, and Retry starts it', + async ({ error, words, kind }) => { + const replacement = await clearCommits() + vi.mocked(adapter.acquire).mockRejectedValueOnce(error()) + const sent = await sendTo(replacement, 'first message') + expect(sent).toMatchObject({ ok: true }) + const clientMessageId = sent.ok ? sent.value.clientMessageId : '' + await vi.waitFor(async () => + expect(await submissionOf(replacement, clientMessageId)).toMatchObject({ + dispatchState: 'rejected', + rejection: { kind } + }) + ) + const rows = await errorRows(replacement) + expect(rows).toHaveLength(1) + expect(rows[0]).toContain(words) + expect(rows.join(' ')).not.toContain('/clear') + expect((await submissionOf(replacement, clientMessageId))?.reason).not.toContain('/clear') + expect(adapter.dispatch).not.toHaveBeenCalled() + + // Retry resends the same words, which starts the agent and delivers them. + expect(await sendTo(replacement, 'first message')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(adapter.dispatch).toHaveBeenCalledTimes(1)) + expect(startsFor(replacement)).toBe(2) + } + ) - it('reruns under the same operation id and starts exactly one replacement', async () => { - refuseReplacementStartOnce() - const params = commandParams('clear') - await expect(host.conversationCommand(caller, params)).rejects.toThrow() - expect(acquisitions).toBe(1) - await clearCommits(params) - expect(acquisitions).toBe(2) + it('starts after a restart between the /clear and the first send', async () => { + const replacement = await clearCommits() + await restartHost() + await host.restoreReadableSessions(store.listVisibleSessionIds()) + expect(store.getRecord(replacement)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + runtimeFence: 1 + }) + expect(host.conversationReplacements().map((entry) => entry.sessionId)).toEqual([replacement]) + expect(await sendTo(replacement, 'first message')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(adapter.dispatch).toHaveBeenCalledTimes(1)) + expect(startsFor(replacement)).toBe(1) }) +}) +describe('what an older build left', () => { /** What an older build left when its clear's outcome was lost: gated every write until now. */ async function restartOverAnOlderBuildsUnconfirmedClear() { const record = store.getRecord(HOST_TEST_SESSION)! @@ -406,289 +629,43 @@ describe('a clear that never committed', () => { await clearCommits() }) - /** A clear whose replacement started but whose commit never landed. */ - async function clearThatDiesBeforeItsCommit(params = commandParams('clear')): Promise { - const commit = store.setConversationCommand.bind(store) - let crashed = false - vi.spyOn(store, 'setConversationCommand').mockImplementation(async (...args) => { - if (!crashed && args[2].phase === 'committed' && args[2].replacementSessionId) { - crashed = true - throw new Error('crash before the commit') - } - return commit(...args) - }) - await expect(host.conversationCommand(caller, params)).rejects.toThrow( - 'crash before the commit' - ) - const [orphan] = store - .listRecords() - .flatMap((record) => (record.sessionId === HOST_TEST_SESSION ? [] : [record.sessionId])) - expect(host.collaboratorsForTests().sessions.get(orphan!)?.child).toBeTruthy() - return orphan! - } - - function otherRecordIds(): string[] { - return store - .listRecords() - .flatMap((record) => (record.sessionId === HOST_TEST_SESSION ? [] : [record.sessionId])) - } - - function startsFor(sessionId: string): number { - return vi - .mocked(adapter.acquire) - .mock.calls.filter(([input]) => input.identity.sessionId === sessionId).length - } - - function sendTo(sessionId: string, text: string) { - const body = hostTestMessage(text) - return host.send(caller, { - body, - envelope: { - sessionId, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(sessionId)!.lease.runtimeFence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId, - fields: { body } - }) - } - }) - } - - /** The chat reads cleared onto `replacement`, which starts its agent for its first message. */ - async function expectClearedOnto(replacement: string): Promise { - expect(store.listVisibleSessionIds()).toEqual([replacement]) - expect(host.conversationReplacements().map((entry) => entry.sessionId)).toEqual([replacement]) - expect(await host.send(caller, sendParams('into the old chat'))).toMatchObject({ - ok: false, - refusal: { details: { reason: 'conversationCleared' } } - }) - const started = startsFor(replacement) - expect(await sendTo(replacement, 'first message')).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(startsFor(replacement)).toBe(started + 1)) - } - - // A restart stopped the replacement the first try started, which leaves it at rest like any - // quiet chat, so the retry switches to it. - it('retried under the same operation id after a crash, finishes onto the replacement it started', async () => { + // The build before this one started the replacement before committing, so a crash between the + // two left a started record nothing points at, and its start in the ledger. + it('founds a fresh replacement beside the orphan an older build started for the same operation id', async () => { const params = commandParams('clear') - const orphan = await clearThatDiesBeforeItsCommit(params) - await restartHost() - await host.restoreReadableSessions(store.listVisibleSessionIds()) - const result = await host.conversationCommand(caller, params) - expect(result).toMatchObject({ - ok: true, - value: { phase: 'committed', state: 'completed', replacementSessionId: orphan } - }) - expect(result.ok && result.value.error).toBeFalsy() - expect(otherRecordIds()).toEqual([orphan]) - expect(startsFor(orphan)).toBe(1) - await expectClearedOnto(orphan) - }) - - it('retried in the same app session once the idle sweep stopped the replacement, finishes onto it', async () => { - const params = commandParams('clear') - const attach = host.attach.bind(host) - // The replacement starts, but the answer the clear gets proves nothing either way. - vi.spyOn(host, 'attach').mockImplementationOnce(async (...args) => { - await attach(...args) - return { - ok: false, - refusal: { code: 'agent_session_operation_capacity', message: 'Too many operations.' } - } - }) - await expect(host.conversationCommand(caller, params)).rejects.toThrow('Too many operations.') - const [orphan] = otherRecordIds() - expect(store.getRecord(orphan!)?.lease.claimStatus).not.toBe('released') - clock += STRUCTURED_AGENT_SESSION_IDLE_MS + 1 - await host.collaboratorsForTests().lifetime.idleSweep.tick() - expect(store.getRecord(orphan!)?.lease).toMatchObject({ claimStatus: 'released' }) - const result = await host.conversationCommand(caller, params) - expect(result).toMatchObject({ - ok: true, - value: { phase: 'committed', state: 'completed', replacementSessionId: orphan } - }) - expect(result.ok && result.value.error).toBeFalsy() - expect(startsFor(orphan!)).toBe(1) - await expectClearedOnto(orphan!) - }) - - it('retried after the replacement definitely failed to start, still reads that failure', async () => { - vi.mocked(adapter.acquire).mockRejectedValueOnce( - new AgentSessionAcquisitionRefusal('Codex is not signed in.', 'notSignedIn') - ) - vi.spyOn(store, 'setConversationCommand').mockRejectedValueOnce( - new Error('crash before the commit') - ) - const params = commandParams('clear') - await expect(host.conversationCommand(caller, params)).rejects.toThrow( - 'crash before the commit' - ) - const [replacement] = otherRecordIds() - expect(store.getRecord(replacement!)?.lease).toMatchObject({ claimStatus: 'released' }) - expect(await host.conversationCommand(caller, params)).toMatchObject({ - ok: true, - value: { - state: 'completed', - replacementSessionId: undefined, - error: 'Codex is not signed in for the selected account. Sign in, then run /clear again.', - failure: { kind: 'notSignedIn' } + const { clientOperationId } = params.envelope + // The older build derived both ids from the operation, then crashed between start and commit. + const digest = createHash('sha256') + .update(JSON.stringify([HOST_TEST_SESSION, caller.callerKey, clientOperationId])) + .digest('hex') + const orphanStart = hostTestAttachParams(null, { + envelope: { + sessionId: `clear-${digest.slice(0, 40)}`, + clientOperationId: `${HOST_TEST_NOW}-${digest.slice(0, 32)}`, + expectedRuntimeFence: null, + payloadFingerprint: '' } }) - expect(startsFor(replacement!)).toBe(1) - expect(store.listVisibleSessionIds()).toEqual([HOST_TEST_SESSION]) - }) - - // The client mints a fresh operation id per press; the host still finds that press's earlier try. - it('retried under a fresh operation id while its replacement runs, finishes onto it and starts no other', async () => { - const orphan = await clearThatDiesBeforeItsCommit() - const result = await host.conversationCommand(caller, commandParams('clear')) - expect(result).toMatchObject({ - ok: true, - value: { phase: 'committed', state: 'completed', replacementSessionId: orphan } - }) - expect(result.ok && result.value.error).toBeFalsy() - expect(otherRecordIds()).toEqual([orphan]) - expect(startsFor(orphan)).toBe(1) - expect(store.listVisibleSessionIds()).toEqual([orphan]) - expect(host.conversationReplacements().map((entry) => entry.sessionId)).toEqual([orphan]) - expect(await sendTo(orphan, 'first message')).toMatchObject({ ok: true }) - }) - - it('retried under a fresh operation id after a restart, finishes onto the replacement it started', async () => { - const orphan = await clearThatDiesBeforeItsCommit() + const orphan = orphanStart.envelope.sessionId + notFromAClear.add(orphan) + expect( + await store.admitMutationOperation({ + callerKey: caller.callerKey, + envelope: params.envelope, + hostFingerprint: params.envelope.payloadFingerprint, + now: clock + }) + ).toMatchObject({ admission: { decision: 'admit' } }) + expect(await host.attach(caller, orphanStart)).toMatchObject({ ok: true }) await restartHost() await host.restoreReadableSessions(store.listVisibleSessionIds()) - const result = await host.conversationCommand(caller, commandParams('clear')) - expect(result).toMatchObject({ - ok: true, - value: { phase: 'committed', state: 'completed', replacementSessionId: orphan } - }) - expect(otherRecordIds()).toEqual([orphan]) - expect(startsFor(orphan)).toBe(1) - expect(store.getRecord(orphan)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null - }) - await expectClearedOnto(orphan) - }) - it("refuses another window's /clear while this one's replacement runs, and runs it once that stops", async () => { - const otherWindow = { callerKey: 'mobile' } - const orphan = await clearThatDiesBeforeItsCommit() - expect(await host.conversationCommand(otherWindow, commandParams('clear'))).toMatchObject({ - ok: false, - refusal: { details: { reason: 'conversationCommandInFlight' } } - }) - expect(otherRecordIds()).toEqual([orphan]) - clock += STRUCTURED_AGENT_SESSION_IDLE_MS + 1 - await host.collaboratorsForTests().lifetime.idleSweep.tick() - const result = await host.conversationCommand(otherWindow, commandParams('clear')) - expect(result).toMatchObject({ - ok: true, - value: { state: 'completed', replacementSessionId: expect.any(String) } - }) - const replacement = result.ok ? result.value.replacementSessionId! : '' + const replacement = await clearCommits(params) expect(replacement).not.toBe(orphan) - // Nothing points at the first window's replacement, so nothing lists, opens or starts it. expect(store.listVisibleSessionIds()).toEqual([replacement]) expect(host.conversationReplacements().map((entry) => entry.sessionId)).toEqual([replacement]) expect(host.collaboratorsForTests().sessions.has(orphan)).toBe(false) - expect(store.getRecord(orphan)?.lease).toMatchObject({ claimStatus: 'released' }) expect(startsFor(orphan)).toBe(1) - }) - - function failNextReplacementStart() { - vi.mocked(adapter.acquire).mockRejectedValueOnce( - new AgentSessionAcquisitionRefusal('Codex is not signed in.', 'notSignedIn') - ) - } - - // The other window's failure is the conversation's latest; this window's own still ended its try. - it('starts a new replacement for a /clear after one that committed its failure, in either window', async () => { - for (const each of [caller, { callerKey: 'mobile' }]) { - failNextReplacementStart() - expect(await host.conversationCommand(each, commandParams('clear'))).toMatchObject({ - ok: true, - value: { replacementSessionId: undefined, failure: { kind: 'notSignedIn' } } - }) - } - const failed = otherRecordIds() - expect(failed).toHaveLength(2) - const replacement = await clearCommits() - expect(failed).not.toContain(replacement) - expect(startsFor(replacement)).toBe(1) - expect(store.listVisibleSessionIds()).toEqual([replacement]) - }) - - it('starts a new replacement after a committed failure whose ledger settlement was lost', async () => { - failNextReplacementStart() - const persist = store.recordOperationOutcome.bind(store) - vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { - if (input.outcome.status === 'succeeded' && input.outcome.conversationCommand) { - throw new Error('crash') - } - return persist(input) - }) - await expect(host.conversationCommand(caller, commandParams('clear'))).rejects.toThrow('crash') - vi.mocked(store.recordOperationOutcome).mockRestore() - const [failed] = otherRecordIds() - const replacement = await clearCommits() - expect(replacement).not.toBe(failed) - expect(store.listVisibleSessionIds()).toEqual([replacement]) - }) - - it("does not gate another window's /clear on a replacement whose stop is only unproven", async () => { - const orphan = await clearThatDiesBeforeItsCommit() - ownerProbe = { outcome: 'indeterminate', reason: 'test' } - await restartHost() - await host.restoreReadableSessions(store.listVisibleSessionIds()) - expect(store.getRecord(orphan)?.lease.claimStatus).not.toBe('released') - const result = await host.conversationCommand({ callerKey: 'mobile' }, commandParams('clear')) - expect(result).toMatchObject({ - ok: true, - value: { state: 'completed', replacementSessionId: expect.any(String) } - }) - expect(result.ok && result.value.replacementSessionId).not.toBe(orphan) - }) - - it('starts afresh when a retry under a new operation id follows a start that definitely failed', async () => { - failNextReplacementStart() - vi.spyOn(store, 'setConversationCommand').mockRejectedValueOnce( - new Error('crash before the commit') - ) - await expect(host.conversationCommand(caller, commandParams('clear'))).rejects.toThrow( - 'crash before the commit' - ) - const [failed] = otherRecordIds() - const result = await host.conversationCommand(caller, commandParams('clear')) - expect(result).toMatchObject({ - ok: true, - value: { state: 'completed', replacementSessionId: expect.any(String) } - }) - expect(result.ok && result.value.error).toBeFalsy() - expect(result.ok && result.value.replacementSessionId).not.toBe(failed) - }) - - it('starts afresh when a retry after a restart follows a try whose start never answered', async () => { - let hostDies!: (error: Error) => void - vi.mocked(adapter.acquire).mockImplementationOnce( - () => new Promise((_, reject) => (hostDies = reject)) - ) - const dying = host.conversationCommand(caller, commandParams('clear')).catch(() => {}) - await vi.waitFor(() => expect(otherRecordIds()).toHaveLength(1)) - const [interrupted] = otherRecordIds() - await restartHost() - await host.restoreReadableSessions(store.listVisibleSessionIds()) - const result = await host.conversationCommand(caller, commandParams('clear')) - expect(result).toMatchObject({ - ok: true, - value: { state: 'completed', replacementSessionId: expect.any(String) } - }) - expect(result.ok && result.value.error).toBeFalsy() - expect(result.ok && result.value.replacementSessionId).not.toBe(interrupted) - hostDies(new Error('the host that started it is gone')) - await dying + expect(startsFor(replacement)).toBe(0) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index 065ead6e3433..4a825d6081d5 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -1,6 +1,4 @@ -import { createHash } from 'node:crypto' -import { isDefinitiveAgentSessionCreateRefusal } from '../../../shared/agent-session-definitive-refusal' -import { parseAgentSessionOperationTimestamp } from '../../../shared/agent-session-host-authority' +import { randomBytes } from 'node:crypto' import type { AgentSessionConversationCommand, AgentSessionConversationCommandResult @@ -9,34 +7,17 @@ import type { AgentSessionMutationEnvelope, AgentSessionMutationResult } from '../../../shared/agent-session-wire' -import { - agentSessionLeaseAdmitsWriter, - agentSessionLeaseIsReleased -} from '../../../shared/agent-session-lease-adjudication' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { - attachFingerprintFields, - type AgentSessionAttachParams -} from './structured-agent-session-attach' import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' -import { - openWithAgent, - structuredAgentSessionFailureWordsContext -} from './structured-agent-session-send-preparation' +import { sendPreparation } from './structured-agent-session-send-preparation' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import type { StructuredAgentSessionHost } from './structured-agent-session-host' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { - conversationCommandInFlight, - conversationCommandBlocked -} from './structured-conversation-command-admission' +import { conversationCommandBlocked } from './structured-conversation-command-admission' import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords, type AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' -import { structuredAgentSessionStartFailureFact } from './structured-agent-session-failure-text' import { carryQueuedMessagesToClearReplacement } from './structured-agent-session-queued-mutations' /** A command's `error` is the sentence its row shows. */ @@ -62,79 +43,13 @@ export type ConversationReplacement = { agent: 'claude' | 'codex' } -/** The replacement a /clear try names, and the operation id of its start. */ -function clearReplacementIds(sessionId: string, callerKey: string, operationId: string) { - const digest = createHash('sha256') - .update(JSON.stringify([sessionId, callerKey, operationId])) - .digest('hex') - return { - sessionId: `clear-${digest.slice(0, 40)}`, - attachOperationId: `${parseAgentSessionOperationTimestamp(operationId)}-${digest.slice(0, 32)}` - } -} - /** - * The try this caller's /clear finishes: the oldest since its last commit whose replacement start - * reached the ledger, else this one. Read from the ledger in admission order, so a retry under a - * fresh operation id finds the replacement an earlier try started. + * `/clear`: one write that points this conversation at a new, at-rest one and moves its tab there. + * The new conversation's first send starts its agent. */ -function clearTryToFinish( - store: AgentSessionRecordStore, - sessionId: string, - callerKey: string, - clearFingerprint: string, - operationId: string -): string { - const committed = store.getRecord(sessionId)?.conversationCommand - let earliest: string | null = null - for (const row of store.listOperationRows()) { - if (row.callerKey !== callerKey || row.fingerprint !== clearFingerprint) { - continue - } - // The record also names a commit whose ledger settlement a crash lost. - if ( - row.outcome.status === 'succeeded' || - (committed?.phase === 'committed' && - committed.callerKey === callerKey && - committed.operationId === row.operationId) - ) { - earliest = null - } else if (earliest === null) { - const start = store.getOperationRow( - callerKey, - clearReplacementIds(sessionId, callerKey, row.operationId).attachOperationId - ) - // Only a start that succeeded left a conversation to finish; replaying any other repeats it. - if (start?.outcome.status === 'succeeded') { - earliest = row.operationId - } - } - } - return earliest ?? operationId -} - -/** Another caller's uncommitted /clear holds a replacement whose agent is running. One whose stop - * is merely unproven gates nothing: only that caller's own start would ever settle it. */ -function otherCallersClearIsLive( - store: AgentSessionRecordStore, - sessionId: string, - callerKey: string, - clearFingerprint: string -): boolean { - return store.listOperationRows().some((row) => { - if (row.callerKey === callerKey || row.fingerprint !== clearFingerprint) { - return false - } - const replacement = store.getRecord( - clearReplacementIds(sessionId, row.callerKey, row.operationId).sessionId - ) - return replacement !== null && agentSessionLeaseAdmitsWriter(replacement.lease) - }) -} - export function runStructuredConversationCommand( context: StructuredAgentSessionMutationContext, - host: Pick, + host: Pick, caller: StructuredAgentSessionCaller, params: ConversationCommandParams ): Promise> { @@ -153,8 +68,8 @@ export function runStructuredConversationCommand( adapter: context.deps.adapter, callerKey: caller.callerKey, envelope, - // Only the provider can do this, so an agent at rest is started first. - prepareSession: openWithAgent(context, params.envelope), + // Starts the agent only to settle a rewind in doubt, as a send does; a /clear itself starts nothing. + prepareSession: sendPreparation(context, envelope), journal: () => context.sessions.get(sessionId)?.journal, publish: (journal) => context.publish(sessionId, journal), flushStreamedEvents: context.flushStreamedEvents, @@ -162,6 +77,8 @@ export function runStructuredConversationCommand( plan: { method: 'agentSession.conversationCommand', fields: { command }, + // Written to the conversation, not the agent, so whoever owns the agent does not matter. + conversationWrite: true, recoverUnknownFromDurableState: true, settledOutcome: (value) => ({ status: 'succeeded', sessionId, conversationCommand: value }), replay: (_ctx, outcome) => { @@ -171,117 +88,52 @@ export function runStructuredConversationCommand( const prior = matching() return prior?.phase === 'committed' ? prior : null }, - // Nothing the chat reads is written before the commit, so a clear with no committed answer - // changed nothing and runs again, finishing the replacement its earliest try started. - rerunWhenReplayMissing: () => command === 'clear', + // The commit is the only write, so a clear with no committed answer changed nothing. + rerunWhenReplayMissing: () => true, run: async (ctx) => { await host.flushStreamedEvents(sessionId) const record = store.getRecord(sessionId)! - const blocked = conversationCommandBlocked(ctx, record) + const blocked = conversationCommandBlocked( + ctx, + record, + context.readChildWork(sessionId), + context.sessions.get(sessionId)?.child ? undefined : 'at-rest' + ) if (blocked) { return { ok: false, refusal: blocked } } - let ids: ReturnType | undefined - if (command === 'clear') { - const clearFingerprint = computeAgentSessionPayloadFingerprint({ - method: 'agentSession.conversationCommand', - sessionId, - fields: { command } - }) - if (otherCallersClearIsLive(store, sessionId, caller.callerKey, clearFingerprint)) { - return { ok: false, refusal: conversationCommandInFlight() } - } - ids = clearReplacementIds( - sessionId, - caller.callerKey, - clearTryToFinish( - store, - sessionId, - caller.callerKey, - clearFingerprint, - clientOperationId - ) - ) - } - const replacementSessionId = ids?.sessionId - const base = { + // Stopped before the marker, so nothing the old agent does can land after the clear. The + // stop releases the lease, which moves its fence: the marker is written at the new one. + await context.stopAgent(sessionId) + const fence = store.getRecord(sessionId)!.lease.runtimeFence + const completed = { command, - runtimeFence: ctx.fence, + runtimeFence: fence, operationId: clientOperationId, callerKey: caller.callerKey, - ...(replacementSessionId ? { replacementSessionId } : {}) - } - if (ids) { - const attach: AgentSessionAttachParams = { - envelope: { - sessionId: ids.sessionId, - clientOperationId: ids.attachOperationId, - expectedRuntimeFence: null, - payloadFingerprint: '' - }, - location: record.location, - accountHome: record.accountHome, - provider: record.provider, - agent: record.provider, - runtimeKind: 'native', - launchArgs: record.launchArgs, - // The options the user chose, which any restart of this chat would replay too. - options: record.options - } - attach.envelope.payloadFingerprint = computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: ids.sessionId, - fields: attachFingerprintFields(attach) - }) - const acquired = await host.attach(caller, attach) - const replacement = store.getRecord(ids.sessionId) - // An earlier try started this replacement and a restart or the idle sweep has since - // stopped it: attach can't replay a settled start, but the new conversation is at rest. - const startedAndAtRest = - replacement !== null && - agentSessionLeaseIsReleased(replacement.lease) && - store.getOperationRow(caller.callerKey, attach.envelope.clientOperationId)?.outcome - .status === 'succeeded' - if (!acquired.ok && !startedAndAtRest) { - if ( - !isDefinitiveAgentSessionCreateRefusal(acquired.refusal.code) && - replacement?.lease.claimStatus !== 'released' - ) { - throw new Error(acquired.refusal.message) - } - // The refusal's message is Orca's log text; the result keeps its situation instead. - const failed = { - ...base, - replacementSessionId: undefined, - phase: 'committed' as const, - state: 'completed' as const, - ...conversationCommandFailure( - structuredAgentSessionStartFailureFact({ - refusal: acquired.refusal, - newSession: true - }), - { ...structuredAgentSessionFailureWordsContext(record), command: 'clear' } - ) - } - await store.setConversationCommand(sessionId, ctx.fence, failed) - return { ok: true, value: failed } - } - // Carry the source's drafts to the replacement, the same for every client version: - // the cards stay visible where the user now is, and no text rides the wire. - // Bookkeeping — a failure is reported and never fails the clear. - await carryQueuedMessagesToClearReplacement(ctx, { - replacementSessionId: ids.sessionId, - replacementJournal: context.sessions.get(ids.sessionId)?.journal, - callerKey: caller.callerKey, - operationId: clientOperationId - }) - } - const completed = { - ...base, + // Only has to be new: the marker is what points at it, and a same-id resend replays it. + replacementSessionId: `clear-${randomBytes(20).toString('hex')}`, phase: 'committed' as const, state: 'completed' as const } - await store.setConversationCommand(sessionId, ctx.fence, completed) + await store.commitConversationClear({ + sessionId, + fence, + command: completed, + claimKeyId: context.deps.claimKeyId, + now: context.now() + }) + // Carry the source's drafts to the replacement, the same for every client version: + // the cards stay visible where the user now is, and no text rides the wire. + // Bookkeeping — a failure is reported and never fails the clear. + await carryQueuedMessagesToClearReplacement(ctx, { + replacementSessionId: completed.replacementSessionId, + // Opened under its own lock, as every open is. + openReplacementJournal: async () => + (await context.conversation(completed.replacementSessionId)).journal, + callerKey: caller.callerKey, + operationId: clientOperationId + }) return { ok: true, value: completed } } } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts index 28eae2dff687..283f7c4f934c 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -370,7 +370,7 @@ it('refuses the command at handover when the provider opened a turn meanwhile (B it('leaves a command whose start failed not sent, beside one start-failure row (B3)', async () => { await attach() - await state.host.close(SESSION) + await state.host.close(SESSION, 'evict') state.acquire.mockRejectedValue(new Error('not signed in')) const params = compactParams() diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts index 7ad7f9bb1bc2..98c04a2e8401 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts @@ -17,7 +17,7 @@ import type { StructuredAgentSessionHost } from './structured-agent-session-host import { mutateStructuredAgentSession, type StructuredAgentSessionMutationContext -} from './structured-agent-session-host-mutations' +} from './structured-agent-session-mutation-context' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import { conversationCommandPlan, @@ -140,6 +140,7 @@ function acceptStructuredConversationCommand( conversationCommandBlocked( ctx, record, + context.readChildWork(ctx.sessionId), context.sessions.get(ctx.sessionId)?.child ? undefined : 'at-rest' ) const blocked = diff --git a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts index aefceacbca4b..f0c9309441d3 100644 --- a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts +++ b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts @@ -87,7 +87,7 @@ describe('unhandled provider frame journal fallback', () => { }) }) - it('renders codex systemError and Claude error result variants', () => { + it('renders codex systemError and Claude error result variants when no typed translator covers the frame', () => { const codex = unhandledProviderFrameJournalItem('codex', 'notification:thread/status/changed', { threadId: 'thread-1', status: { type: 'systemError' } diff --git a/src/main/observability/instrumentation.ts b/src/main/observability/instrumentation.ts index 8569ab6ef048..bba248cd663c 100644 --- a/src/main/observability/instrumentation.ts +++ b/src/main/observability/instrumentation.ts @@ -297,7 +297,6 @@ export type WorktreeRemoveStage = | 'git_remove' | 'metadata_purge' | 'pty_sweep' - | 'trash_rename' | 'watcher_gate' /** Wrap one stage of a worktree removal. Children share the parent's `kind` so `kind`-filtered diff --git a/src/main/opencode-usage/scanner.test.ts b/src/main/opencode-usage/scanner.test.ts index 88559b0ec7d0..0e5c39eb69a1 100644 --- a/src/main/opencode-usage/scanner.test.ts +++ b/src/main/opencode-usage/scanner.test.ts @@ -175,17 +175,6 @@ describe('parseOpenCodeUsageRow', () => { }) describe('attributeOpenCodeUsageEvent', () => { - it('attributes cwd paths under dotdot-prefixed child directories to the worktree', async () => { - const attributed = await attributeOpenCodeUsageEvent( - usageEvent(`${WORKTREE}/..fixtures/session`), - await resolveWorktree() - ) - - expect(attributed?.projectKey).toBe('worktree:repo-1::/workspace/repo') - expect(attributed?.projectLabel).toBe('Repo') - expect(attributed?.worktreeId).toBe('repo-1::/workspace/repo') - }) - it('does not attribute true parent-directory escapes to the worktree', async () => { const attributed = await attributeOpenCodeUsageEvent( usageEvent(`${WORKTREE}/../other/session`), @@ -195,23 +184,6 @@ describe('attributeOpenCodeUsageEvent', () => { expect(attributed?.projectKey).toBe('cwd:/workspace/repo/../other/session') expect(attributed?.worktreeId).toBeNull() }) - - it('does not treat different Windows drives as containing paths', async () => { - const attributed = await attributeOpenCodeUsageEvent( - usageEvent('D:\\other\\repo'), - await createUsageWorktreeResolver([ - { - repoId: 'repo-1', - worktreeId: 'repo-1::C:\\repo', - path: 'C:\\repo', - displayName: 'Repo' - } - ]) - ) - - expect(attributed?.projectKey).toBe('cwd:d:/other/repo') - expect(attributed?.worktreeId).toBeNull() - }) }) describe('parseOpenCodeUsageDatabase', () => { diff --git a/src/main/opencode/hook-plugin-opencode2-setup.test.ts b/src/main/opencode/hook-plugin-opencode2-setup.test.ts index e9e38334d3e6..1ada5ea1a53d 100644 --- a/src/main/opencode/hook-plugin-opencode2-setup.test.ts +++ b/src/main/opencode/hook-plugin-opencode2-setup.test.ts @@ -205,6 +205,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag expect(posts).toEqual( expect.arrayContaining([ expect.objectContaining({ + opencodeMajor: 2, payload: expect.objectContaining({ hook_event_name: 'SessionBusy' }) }), expect.objectContaining({ @@ -222,6 +223,33 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag expect(subscriptionSignal?.aborted).toBe(true) }) + // Why: the host's OpenCode 1 session binder must stay off OpenCode 2 posts only. + it('declares no OpenCode major on posts from the OpenCode 1 server() entry', async () => { + process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' + const bodies: Record[] = [] + globalThis.fetch = vi.fn(async (_input, init) => { + bodies.push(record(JSON.parse(String(init?.body))) ?? {}) + return new Response('{}', { status: 200 }) + }) + const module = await loadPluginModule( + agent === 'opencode2' + ? _internals.getOpenCode2PluginSource() + : _internals.getOpenCodePluginSource() + ) + const hooks = await module.default?.server?.({ + client: { session: { get: async () => ({ data: { id: 'ses_root' } }) } } + }) + await hooks?.event({ + event: { + type: 'session.status', + properties: { sessionID: 'ses_root', status: { type: 'busy' } } + } + }) + await vi.waitFor(() => expect(bodies.length).toBeGreaterThan(0)) + expect(bodies.filter((body) => 'opencodeMajor' in body)).toEqual([]) + await hooks?.dispose?.() + }) + it('maps permission, form, and text events through the live setup bridge', async () => { process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' const posts: { body: PostBody }[] = [] diff --git a/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts b/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts new file mode 100644 index 000000000000..29eefd774827 --- /dev/null +++ b/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts @@ -0,0 +1,772 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: getPathMock + } +})) + +import { _internals } from './hook-service' + +type Post = { + paneKey?: string + opencodeMajor?: number + payload?: { hook_event_name?: string; sessionID?: string } +} +type BusEvent = { type: string; data: Record } +type PluginModule = { + default?: { setup?: (ctx: unknown) => Promise<(() => Promise) | undefined> } +} + +const PANE_A = 'tabA:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const PANE_B = 'tabB:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const SES_A = 'ses_f161fd85fffeieT0zYt80ZKorS' +const SES_B = 'ses_f161fcd7affeffV0OOrMCrTxtQ' +const ENV_KEYS = [ + 'ORCA_PANE_KEY', + 'ORCA_OPENCODE_AGENT', + 'ORCA_AGENT_HOOK_ENDPOINT', + 'ORCA_AGENT_HOOK_PORT', + 'ORCA_AGENT_HOOK_TOKEN' +] as const + +// Event shapes as OpenCode 2.0.14 delivers them to both server and TUI plugins. +function turn(sessionID: string, text: string): { start: BusEvent[]; finish: BusEvent[] } { + const assistantMessageID = `msg_${sessionID}_a` + return { + start: [ + { + type: 'session.created', + data: { sessionID, projectID: 'global', location: { directory: '/proj' }, subpath: '' } + }, + { + type: 'session.inbox.enqueued', + data: { + sessionID, + inboxID: `msg_${sessionID}_u`, + item: { type: 'user', payload: { text, files: [] }, delivery: 'steer' } + } + }, + { type: 'session.execution.started', data: { sessionID } }, + { type: 'session.step.started', data: { sessionID, assistantMessageID, agent: 'build' } }, + { type: 'session.text.started', data: { sessionID, assistantMessageID, ordinal: 0 } } + ], + finish: [ + { + type: 'session.text.ended', + data: { sessionID, assistantMessageID, ordinal: 0, text: 'tick0 tick1 ' } + }, + { type: 'session.step.ended', data: { sessionID, assistantMessageID, finish: 'stop' } }, + { type: 'session.execution.succeeded', data: { sessionID } } + ] + } +} + +type Blocker = { id: string; sessionID: string; [key: string]: unknown } + +function toBlocker(value: unknown): Blocker { + const record = typeof value === 'object' && value !== null ? { ...value } : {} + const id = 'id' in record ? String(record.id) : '' + const sessionID = 'sessionID' in record ? String(record.sessionID) : '' + return { ...record, id, sessionID } +} + +/** One pane's TUI: its route, its view of the shared session store, and the shared event bus. */ +function fakeTui(version = '2.0.14') { + const listeners = new Set<(event: { details: BusEvent }) => void>() + const sessions = new Map() + const running = new Set() + const permissions = new Map() + const forms = new Map() + // What the server answers when the TUI re-fetches a permission list (reconnect). + const serverPermissions = new Map() + let permissionFetch: Promise = Promise.resolve() + // Why: OpenCode keeps storage.memory across plugin hot reloads within one TUI process. + const memories = new Map() + let route: { type: string; sessionID?: string } = { type: 'home' } + const rootOf = (id: string): string => { + let current = sessions.get(id) + while (current?.parentID && sessions.has(current.parentID)) { + current = sessions.get(current.parentID) + } + return current?.id ?? id + } + const without = (map: Map, sessionID: string, id: unknown): void => { + map.set( + sessionID, + (map.get(sessionID) ?? []).filter((item) => item.id !== id) + ) + } + const listen = vi.fn((handler: (event: { details: BusEvent }) => void) => { + listeners.add(handler) + return () => listeners.delete(handler) + }) + const ctx = { + app: { version, channel: 'latest' }, + ui: { router: { current: () => route } }, + storage: { + memory: (key: string, options: { initial: Record }) => { + if (!memories.has(key)) { + const value = structuredClone(options.initial) + memories.set(key, [value, (mutate: (draft: typeof value) => void) => mutate(value)]) + } + return memories.get(key) + } + }, + client: { + session: { get: async ({ sessionID }: { sessionID: string }) => sessions.get(sessionID) } + }, + data: { + listen, + session: { + get: (id: string) => sessions.get(id), + root: rootOf, + family: (id: string) => + [...sessions.keys()].filter((member) => rootOf(member) === rootOf(id)), + status: (id: string) => (running.has(id) ? 'running' : 'idle'), + permission: { + list: (id: string) => permissions.get(id), + sync: async (id: string) => { + await permissionFetch + permissions.set(id, [...(serverPermissions.get(id) ?? [])]) + } + }, + form: { list: (id: string) => forms.get(id), sync: async () => {} } + } + } + } + return { + ctx, + listen, + serverPermissions, + permissions, + navigate(sessionID: string) { + route = { type: 'session', sessionID } + }, + // The session data stops reporting a run without this TUI seeing its end event. + loseEnd(sessionID: string) { + running.delete(sessionID) + }, + // The session data reports a run whose start this TUI never saw. + loseStart(sessionID: string) { + running.add(sessionID) + }, + // Holds the next permission list fetch until the returned release is called. + holdPermissionFetch(): () => void { + let release = (): void => {} + permissionFetch = new Promise((resolve) => { + release = resolve + }) + return release + }, + // Applies an event to the session data first, then to plugin listeners, as OpenCode does. + emit(event: BusEvent) { + const sessionID = String(event.data.sessionID) + if (event.type === 'session.created') { + const parentID = typeof event.data.parentID === 'string' ? event.data.parentID : undefined + sessions.set(sessionID, { id: sessionID, parentID }) + } else if (event.type === 'session.execution.started') { + running.add(sessionID) + } else if (event.type === 'session.execution.succeeded') { + running.delete(sessionID) + } else if (event.type === 'permission.asked') { + permissions.set(sessionID, [...(permissions.get(sessionID) ?? []), toBlocker(event.data)]) + } else if (event.type === 'permission.replied') { + without(permissions, sessionID, event.data.requestID) + } else if (event.type === 'form.created') { + const form = toBlocker(event.data.form) + forms.set(form.sessionID, [...(forms.get(form.sessionID) ?? []), form]) + } else if (event.type === 'form.replied' || event.type === 'form.cancelled') { + without(forms, sessionID, event.data.id) + } + for (const handler of listeners) { + handler({ details: event }) + } + } + } +} + +describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { + let tempDir: string + let savedFetch: typeof globalThis.fetch + let savedEnv: Record + let savedArgv: string[] + let posts: Post[] + // Every post in arrival order, including the start boundary each fresh TUI lands. + let allPosts: Post[] + let failPosts: boolean + let postDelayMs: number + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-opencode-tui-adapter-')) + savedFetch = globalThis.fetch + savedArgv = process.argv + savedEnv = {} + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key] + } + process.env.ORCA_OPENCODE_AGENT = 'opencode' + delete process.env.ORCA_AGENT_HOOK_ENDPOINT + process.env.ORCA_AGENT_HOOK_PORT = '59999' + process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token' + posts = [] + allPosts = [] + failPosts = false + postDelayMs = 0 + globalThis.fetch = vi.fn(async (_input, init) => { + const body = JSON.parse(String(init?.body)) + if (postDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, postDelayMs)) + } + if (failPosts) { + return new Response('{}', { status: 500 }) + } + allPosts.push(body) + // Why apart: a fresh TUI's start boundary names no session; per-session cases assert what follows it. + if ( + body.payload?.hook_event_name !== 'SessionStart' || + body.payload.sessionID !== undefined + ) { + posts.push(body) + } + return new Response('{}', { status: 200 }) + }) + }) + + afterEach(() => { + globalThis.fetch = savedFetch + process.argv = savedArgv + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = savedEnv[key] + } + } + rmSync(tempDir, { recursive: true, force: true }) + }) + + async function loadPlugin(dir = tempDir): Promise { + // Why: a unique basename per load defeats the ESM module cache, like a separate process. + const pluginPath = join(dir, `orca-opencode-status-${Math.random().toString(36).slice(2)}.mjs`) + writeFileSync(pluginPath, _internals.getOpenCodePluginSource()) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the generated module's default export is exercised below and fails the test if absent. + return (await import(pathToFileURL(pluginPath).href)) as PluginModule + } + + const summary = (list: Post[]): string[] => + list.map((post) => `${post.payload?.hook_event_name}:${post.payload?.sessionID}`) + + async function runPane( + paneKey: string, + ownSession: string, + script: (tui: ReturnType) => Promise + ): Promise { + process.env.ORCA_PANE_KEY = paneKey + const start = posts.length + const tui = fakeTui() + const cleanup = await (await loadPlugin()).default?.setup?.(tui.ctx) + await script(tui) + await vi.waitFor(() => { + expect(summary(posts.slice(start)).at(-1)).toBe(`SessionIdle:${ownSession}`) + }) + await cleanup?.() + return posts.slice(start) + } + + const tick = (ms = 20): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + const pump = async (tui: ReturnType, events: BusEvent[]): Promise => { + for (const event of events) { + tui.emit(event) + await tick() + } + } + + // Captured s2 shape: pane A's long turn overlapped by pane B's short one on one server. + it('gives each overlapping pane only its own session and its own Idle', async () => { + const a = turn(SES_A, 'A long SLEEP=12') + const b = turn(SES_B, 'B short SLEEP=3') + const bus = [...a.start, ...b.start, ...b.finish, ...a.finish] + const paneA = await runPane(PANE_A, SES_A, async (tui) => { + tui.navigate(SES_A) + await pump(tui, bus) + }) + const paneB = await runPane(PANE_B, SES_B, async (tui) => { + await pump(tui, [...a.start]) + tui.navigate(SES_B) + await pump(tui, [...b.start, ...b.finish, ...a.finish]) + }) + + expect(paneA.every((post) => post.paneKey === PANE_A)).toBe(true) + expect(paneB.every((post) => post.paneKey === PANE_B)).toBe(true) + // Keeps the host's OpenCode 1 session binder off these posts. + expect([...paneA, ...paneB].every((post) => post.opencodeMajor === 2)).toBe(true) + expect(new Set(paneA.map((post) => post.payload?.sessionID))).toEqual(new Set([SES_A])) + expect(new Set(paneB.map((post) => post.payload?.sessionID))).toEqual(new Set([SES_B])) + for (const [list, session] of [ + [paneA, SES_A], + [paneB, SES_B] + ] as const) { + const names = summary(list) + expect(names[0]).toBe(`SessionStart:${session}`) + expect(names).toContain(`SessionBusy:${session}`) + expect(names.at(-1)).toBe(`SessionIdle:${session}`) + } + }) + + // Captured s5 shape: the TUI's route reached the new session after execution had started. + it('hydrates a session whose route switch lands after execution started', async () => { + const b = turn(SES_B, 'B tui SLEEP=4') + const paneB = await runPane(PANE_B, SES_B, async (tui) => { + await pump(tui, b.start) + expect(posts).toHaveLength(0) + tui.navigate(SES_B) + await vi.waitFor(() => { + expect(summary(posts)).toContain(`SessionBusy:${SES_B}`) + }) + await pump(tui, b.finish) + }) + + const names = summary(paneB) + expect(names.slice(0, 3)).toEqual([ + `SessionStart:${SES_B}`, + `MessagePart:${SES_B}`, + `SessionBusy:${SES_B}` + ]) + expect(names.at(-1)).toBe(`SessionIdle:${SES_B}`) + }) + + it('keeps a started turn until it settles after the route moves on', async () => { + const a = turn(SES_A, 'A long') + const paneA = await runPane(PANE_A, SES_A, async (tui) => { + tui.navigate(SES_A) + await pump(tui, a.start) + tui.navigate('ses_other_idle_session') + await tick(250) + await pump(tui, a.finish) + }) + expect(summary(paneA)).toContain(`SessionBusy:${SES_A}`) + expect(summary(paneA).at(-1)).toBe(`SessionIdle:${SES_A}`) + }) + + it('reports nothing for sessions the pane never showed', async () => { + process.env.ORCA_PANE_KEY = PANE_A + const tui = fakeTui() + const cleanup = await (await loadPlugin()).default?.setup?.(tui.ctx) + const b = turn(SES_B, 'another pane') + await pump(tui, [...b.start, ...b.finish]) + await tick(250) + await cleanup?.() + expect(posts).toEqual([]) + }) + + // Why: a turn the TUI did not see end must not hold the pane Working (e.g. across a reconnect). + it('settles an owned turn once the session data says it ended without an end event', async () => { + process.env.ORCA_PANE_KEY = PANE_A + const tui = fakeTui() + const cleanup = await (await loadPlugin()).default?.setup?.(tui.ctx) + tui.navigate(SES_A) + await pump(tui, turn(SES_A, 'A').start) + tui.loseEnd(SES_A) + await vi.waitFor(() => { + expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) + }) + await cleanup?.() + }) + + // Why: a turn that fails fast arrives as one burst; each level must post once, not flicker. + it('does not settle a turn early while its events are still queued', async () => { + process.env.ORCA_PANE_KEY = PANE_A + const tui = fakeTui() + const cleanup = await (await loadPlugin()).default?.setup?.(tui.ctx) + tui.navigate(SES_A) + await tick() + const a = turn(SES_A, 'A fails fast') + for (const event of [...a.start, ...a.finish]) { + tui.emit(event) + } + await vi.waitFor(() => { + expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) + }) + await tick(250) + await cleanup?.() + const statuses = posts + .map((post) => post.payload?.hook_event_name) + .filter((name) => name === 'SessionBusy' || name === 'SessionIdle') + expect(statuses).toEqual(['SessionBusy', 'SessionIdle']) + }) + + it('re-derives Working for an owned session whose start it missed', async () => { + process.env.ORCA_PANE_KEY = PANE_A + const tui = fakeTui() + const cleanup = await (await loadPlugin()).default?.setup?.(tui.ctx) + tui.navigate(SES_A) + const first = turn(SES_A, 'A') + await pump(tui, [...first.start, ...first.finish]) + await vi.waitFor(() => { + expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) + }) + tui.loseStart(SES_A) + await vi.waitFor(() => { + expect(summary(posts).at(-1)).toBe(`SessionBusy:${SES_A}`) + }) + tui.loseEnd(SES_A) + await vi.waitFor(() => { + expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) + }) + await cleanup?.() + }) + + describe('derived from the session data', () => { + const SES_CHILD = 'ses_f161fe00affeChildSubagent1' + const statuses = (list: Post[]): string[] => + summary(list).filter((name) => + /^(SessionBusy|SessionIdle|PermissionRequest|AskUserQuestion):/.test(name) + ) + const start = async ( + tui: ReturnType + ): Promise<(() => Promise) | undefined> => { + process.env.ORCA_PANE_KEY = PANE_A + return (await loadPlugin()).default?.setup?.(tui.ctx) + } + const childStart = (parentID: string): BusEvent[] => [ + { type: 'session.created', data: { sessionID: SES_CHILD, parentID } }, + { type: 'session.execution.started', data: { sessionID: SES_CHILD } } + ] + const permission = (sessionID: string): BusEvent => ({ + type: 'permission.asked', + data: { id: 'per_1', sessionID, action: 'bash', resources: ['rm -rf build'] } + }) + + // Why: #23700 left this for TUI panes; a reload that misses the end must still reach Done. + it('shows Done for a turn that ended while the plugin was reloading', async () => { + const tui = fakeTui() + const firstGeneration = await start(tui) + tui.navigate(SES_A) + const a = turn(SES_A, 'A') + await pump(tui, a.start) + await vi.waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) + const beforeReload = posts.length + await firstGeneration?.() + expect(posts).toHaveLength(beforeReload) + await pump(tui, a.finish) + const secondGeneration = await start(tui) + await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await secondGeneration?.() + expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`, `SessionIdle:${SES_A}`]) + }) + + it('keeps a navigated-away turn across a reload and settles it after', async () => { + const tui = fakeTui() + const firstGeneration = await start(tui) + tui.navigate(SES_A) + const a = turn(SES_A, 'A') + await pump(tui, a.start) + tui.navigate(SES_B) + await tick(150) + await firstGeneration?.() + const secondGeneration = await start(tui) + await tick(150) + expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`]) + await pump(tui, a.finish) + await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await secondGeneration?.() + }) + + it('re-posts a level the previous generation could not deliver', async () => { + const tui = fakeTui() + failPosts = true + const firstGeneration = await start(tui) + tui.navigate(SES_A) + await pump(tui, turn(SES_A, 'A').start) + await tick(50) + await firstGeneration?.() + failPosts = false + const secondGeneration = await start(tui) + await vi.waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) + await secondGeneration?.() + }) + + // Why: with posts slower than events, the old adapter's decisions trailed the data and flickered. + it('posts each level once and in order while posts are slow', async () => { + postDelayMs = 120 + const tui = fakeTui() + const cleanup = await start(tui) + tui.navigate(SES_A) + const a = turn(SES_A, 'A') + const b = turn(SES_B, 'another pane') + for (const event of [...a.start, ...b.start, ...b.finish, ...a.finish]) { + tui.emit(event) + } + await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`), { + timeout: 3000 + }) + await tick(300) + await cleanup?.() + // The reply text lands after Busy and before Idle, so Done shows it. + expect(summary(posts)).toEqual([ + `SessionStart:${SES_A}`, + `MessagePart:${SES_A}`, + `SessionBusy:${SES_A}`, + `MessagePart:${SES_A}`, + `SessionIdle:${SES_A}` + ]) + expect(posts.at(-2)?.payload).toMatchObject({ role: 'assistant', text: 'tick0 tick1 ' }) + expect(summary(allPosts)[0]).toBe('SessionStart:undefined') + }) + + // Why: Orca may still show this pane a status another process left, e.g. an older shared-service + // plugin that posted another pane's turn here before an upgrade. + it('lands one start boundary in a freshly started TUI, and none on a reload after Done', async () => { + const tui = fakeTui() + const firstGeneration = await start(tui) + await vi.waitFor(() => expect(summary(allPosts)).toEqual(['SessionStart:undefined'])) + tui.navigate(SES_A) + const a = turn(SES_A, 'A') + await pump(tui, [...a.start, ...a.finish]) + await vi.waitFor(() => expect(summary(allPosts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await firstGeneration?.() + const secondGeneration = await start(tui) + await tick(250) + await secondGeneration?.() + expect(summary(allPosts).at(-1)).toBe(`SessionIdle:${SES_A}`) + expect(summary(allPosts).filter((name) => name.startsWith('SessionStart:'))).toEqual([ + 'SessionStart:undefined', + `SessionStart:${SES_A}` + ]) + }) + + it('lands the start boundary only once across reloads of an idle pane', async () => { + const tui = fakeTui() + const firstGeneration = await start(tui) + await vi.waitFor(() => expect(summary(allPosts)).toEqual(['SessionStart:undefined'])) + await firstGeneration?.() + const secondGeneration = await start(tui) + await tick(150) + await secondGeneration?.() + expect(summary(allPosts)).toEqual(['SessionStart:undefined']) + }) + + it('names the idle session a freshly started TUI shows', async () => { + const tui = fakeTui() + tui.navigate(SES_B) + const cleanup = await start(tui) + await vi.waitFor(() => expect(summary(allPosts)).toEqual([`SessionStart:${SES_B}`])) + await cleanup?.() + }) + + it('posts Working instead of the start boundary when the route shows a running turn', async () => { + const tui = fakeTui() + tui.navigate(SES_A) + tui.loseStart(SES_A) + const cleanup = await start(tui) + await vi.waitFor(() => expect(summary(allPosts)).toEqual([`SessionBusy:${SES_A}`])) + await tick(150) + await cleanup?.() + expect(summary(allPosts)).toEqual([`SessionBusy:${SES_A}`]) + }) + + // Why: without plugin memory a reload cannot be told from a start, so it must not reset a Done. + it('lands no start boundary where OpenCode keeps no plugin memory', async () => { + const tui = fakeTui() + Reflect.deleteProperty(tui.ctx, 'storage') + const cleanup = await start(tui) + await tick(150) + await cleanup?.() + expect(allPosts).toEqual([]) + }) + + it('stays Working while a child session holds the turn, even if its end is missed', async () => { + const tui = fakeTui() + const cleanup = await start(tui) + tui.navigate(SES_A) + const a = turn(SES_A, 'A spawns a background task') + await pump(tui, [...a.start, ...childStart(SES_A), ...a.finish]) + await tick(250) + expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`]) + tui.loseEnd(SES_CHILD) + await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await cleanup?.() + expect(posts.every((post) => post.payload?.sessionID === SES_A)).toBe(true) + }) + + it("shows a child's permission as Needs input on the root, then Working after the reply", async () => { + const tui = fakeTui() + const cleanup = await start(tui) + tui.navigate(SES_A) + const a = turn(SES_A, 'A') + await pump(tui, [...a.start, ...childStart(SES_A), permission(SES_CHILD)]) + await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + await pump(tui, [ + { type: 'permission.replied', data: { sessionID: SES_CHILD, requestID: 'per_1' } }, + { type: 'session.execution.succeeded', data: { sessionID: SES_CHILD } }, + ...a.finish + ]) + await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await cleanup?.() + expect(statuses(posts)).toEqual([ + `SessionBusy:${SES_A}`, + `PermissionRequest:${SES_A}`, + `SessionBusy:${SES_A}`, + `SessionIdle:${SES_A}` + ]) + }) + + // Why: Orca reads every MessagePart as Working, so reply text must not bury an open request. + it('keeps Needs input while the root streams text beside a waiting background child', async () => { + const tui = fakeTui() + const cleanup = await start(tui) + tui.navigate(SES_A) + const a = turn(SES_A, 'A spawns a background task') + await pump(tui, [...a.start, ...childStart(SES_A), permission(SES_CHILD)]) + await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + await pump(tui, a.finish) + await tick(300) + expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`) + await cleanup?.() + }) + + it('clears Needs input answered while disconnected once the TUI reconnects', async () => { + const tui = fakeTui() + const cleanup = await start(tui) + tui.navigate(SES_A) + await pump(tui, [...turn(SES_A, 'A').start, permission(SES_A)]) + await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + // The user browses away; the reply lands while this TUI is disconnected. + tui.navigate(SES_B) + await pump(tui, [{ type: 'server.connected', data: {} }]) + await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionBusy:${SES_A}`)) + await cleanup?.() + }) + + it('ignores a reply that a reconnect fetch restores', async () => { + const tui = fakeTui() + const cleanup = await start(tui) + tui.navigate(SES_A) + await pump(tui, [...turn(SES_A, 'A').start, permission(SES_A)]) + await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + const release = tui.holdPermissionFetch() + tui.serverPermissions.set(SES_A, [...(tui.permissions.get(SES_A) ?? [])]) + await pump(tui, [ + { type: 'server.connected', data: {} }, + { type: 'permission.replied', data: { sessionID: SES_A, requestID: 'per_1' } } + ]) + release() + await tick(250) + expect(tui.permissions.get(SES_A)).toHaveLength(1) + expect(summary(posts).at(-1)).toBe(`SessionBusy:${SES_A}`) + await cleanup?.() + }) + + // Why: an Orca restart moves the hook endpoint while the pane's level stays the same. + it('re-posts the current level once the hook endpoint moves', async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) + try { + const tui = fakeTui() + const cleanup = await start(tui) + tui.navigate(SES_A) + await pump(tui, turn(SES_A, 'A').start) + vi.advanceTimersByTime(200) + await vi.waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) + process.env.ORCA_AGENT_HOOK_PORT = '59998' + vi.advanceTimersByTime(5000) + await vi.waitFor(() => + expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`, `SessionBusy:${SES_A}`]) + ) + vi.advanceTimersByTime(5000) + await tick(50) + expect(statuses(posts)).toHaveLength(2) + await cleanup?.() + } finally { + vi.useRealTimers() + } + }) + + it('does not pin Needs input on a request the data kept after its turn ended', async () => { + const tui = fakeTui() + const cleanup = await start(tui) + tui.navigate(SES_A) + const a = turn(SES_A, 'A') + await pump(tui, [...a.start, permission(SES_A)]) + await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + await pump(tui, a.finish) + await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await tick(250) + expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) + await cleanup?.() + }) + }) + + it.each([ + ['an OpenCode 1 TUI', () => fakeTui('1.18.33')], + ['the other pane variant', () => fakeTui()], + ['a TUI outside an Orca pane', () => fakeTui()] + ])('stays silent in %s', async (label, make) => { + if (label === 'the other pane variant') { + process.env.ORCA_OPENCODE_AGENT = 'opencode2' + } + process.env.ORCA_PANE_KEY = PANE_A + if (label === 'a TUI outside an Orca pane') { + delete process.env.ORCA_PANE_KEY + } + const tui = make() + const cleanup = await (await loadPlugin()).default?.setup?.(tui.ctx) + expect(tui.listen).not.toHaveBeenCalled() + await cleanup?.() + }) + + describe('server plugin stand-down', () => { + function serverContext() { + const subscribe = vi.fn(async function* () {}) + const hook = vi.fn(async () => ({ dispose: vi.fn() })) + return { + subscribe, + hook, + ctx: { + session: { hook, get: async () => undefined }, + event: { subscribe } + } + } + } + const installTuiCopy = (): void => { + mkdirSync(join(tempDir, 'orca-opencode-status-tui')) + writeFileSync(join(tempDir, 'orca-opencode-status-tui', 'tui.js'), '') + } + + it('reports nothing from a serve process when the TUI copy is installed beside it', async () => { + installTuiCopy() + process.argv = [...savedArgv, 'serve', '--service'] + const server = serverContext() + const cleanup = await (await loadPlugin()).default?.setup?.(server.ctx) + expect(server.subscribe).not.toHaveBeenCalled() + expect(server.hook).not.toHaveBeenCalled() + await cleanup?.() + }) + + it('stands down in a --standalone private server too', async () => { + installTuiCopy() + process.argv = [...savedArgv, 'serve', '--stdio', '--port', '0'] + const server = serverContext() + const cleanup = await (await loadPlugin()).default?.setup?.(server.ctx) + expect(server.subscribe).not.toHaveBeenCalled() + await cleanup?.() + }) + + // Why: an older SSH relay installs this file but not the TUI copy; nothing else would report. + it('keeps reporting from a serve process whose installer wrote no TUI copy', async () => { + process.argv = [...savedArgv, 'serve', '--service'] + const server = serverContext() + const cleanup = await (await loadPlugin()).default?.setup?.(server.ctx) + expect(server.subscribe).toHaveBeenCalled() + await cleanup?.() + }) + }) +}) diff --git a/src/main/opencode/hook-plugin-run-process-session-start.test.ts b/src/main/opencode/hook-plugin-run-process-session-start.test.ts new file mode 100644 index 000000000000..0854b8ddfa26 --- /dev/null +++ b/src/main/opencode/hook-plugin-run-process-session-start.test.ts @@ -0,0 +1,97 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>() +})) + +vi.mock('electron', () => ({ + app: { getPath: getPathMock } +})) + +import { _internals } from './hook-service' + +type PluginHooks = { + event: (input: { event: { type: string; properties?: unknown } }) => Promise +} +type PluginModule = { OrcaOpenCodeStatusPlugin?: (ctx: unknown) => Promise } + +const ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN'] as const + +// OpenCode 1 `run` loads the plugin in-process; the host already shows the run as Working from the +// process, so a session-start idle row there would only blink the pane. +describe('OpenCode plugin session start inside a `run` process', () => { + let tempDir: string + let events: string[] + let savedEnv: Record + let savedArgv: string[] + let savedFetch: typeof globalThis.fetch + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-opencode-run-process-')) + events = [] + savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])) + savedArgv = process.argv + savedFetch = globalThis.fetch + process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' + process.env.ORCA_AGENT_HOOK_PORT = '45678' + process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token' + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + const body: unknown = JSON.parse(String(init?.body)) + const payload = + typeof body === 'object' && body !== null && 'payload' in body ? body.payload : null + if (typeof payload === 'object' && payload !== null && 'hook_event_name' in payload) { + events.push(String(payload.hook_event_name)) + } + return new Response(null, { status: 204 }) + }) + }) + + afterEach(() => { + process.argv = savedArgv + globalThis.fetch = savedFetch + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = savedEnv[key] + } + } + rmSync(tempDir, { recursive: true, force: true }) + }) + + async function sessionStartEvents(argv: string[]): Promise { + events = [] + process.argv = argv + const pluginPath = join(tempDir, `plugin-${Math.random().toString(36).slice(2)}.mjs`) + writeFileSync(pluginPath, _internals.getOpenCodePluginSource()) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the generated plugin module exports this factory; the test calls it once. + const module = (await import(pathToFileURL(pluginPath).href)) as PluginModule + const hooks = await module.OrcaOpenCodeStatusPlugin?.({}) + await hooks?.event({ + event: { type: 'session.created', properties: { info: { id: 'root' } } } + }) + return events + } + + it('posts no session start from an `opencode run` process', async () => { + await expect( + sessionStartEvents(['bun', '/$bunfs/root/opencode', 'run', 'fix the bug']) + ).resolves.toEqual([]) + await expect( + sessionStartEvents(['/usr/local/bin/opencode', '--log-level', 'INFO', 'run', 'hi']) + ).resolves.toEqual([]) + }) + + it('still posts it from the TUI and from `serve`', async () => { + await expect(sessionStartEvents(['bun', '/$bunfs/root/opencode'])).resolves.toEqual([ + 'SessionStart' + ]) + await expect( + sessionStartEvents(['bun', '/$bunfs/root/opencode', 'serve', '--port', '4096']) + ).resolves.toEqual(['SessionStart']) + }) +}) diff --git a/src/main/opencode/hook-service-startup-refresh.test.ts b/src/main/opencode/hook-service-startup-refresh.test.ts new file mode 100644 index 000000000000..b4b4b444f9b0 --- /dev/null +++ b/src/main/opencode/hook-service-startup-refresh.test.ts @@ -0,0 +1,134 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + utimesSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { setAppEnvironment } from '../../shared/app-environment' +import { OpenCodeHookService, openCode2HookService, getOpenCode2PluginSource } from './hook-service' +import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' + +const PLUGIN = 'orca-opencode2-status.js' +const TUI_ENTRY = join('orca-opencode2-status-tui', 'tui.js') + +describe('OpenCodeHookService.refreshInstalledPlugins (app start)', () => { + let userDataDir: string + const originalXdgConfigHome = process.env.XDG_CONFIG_HOME + + beforeAll(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'orca-opencode-startup-refresh-')) + process.env.XDG_CONFIG_HOME = join(userDataDir, 'xdg') + }) + + beforeEach(() => { + setAppEnvironment({ + getPath: (name: string) => { + if (name === 'userData') { + return userDataDir + } + throw new Error(`unexpected getPath(${name})`) + }, + getAppPath: () => process.cwd(), + getVersion: () => '0.0.0-test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) + }) + + afterEach(() => { + rmSync(join(userDataDir, 'xdg'), { recursive: true, force: true }) + rmSync(join(userDataDir, 'opencode2-config-overlays'), { recursive: true, force: true }) + }) + + afterAll(() => { + if (originalXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = originalXdgConfigHome + } + rmSync(userDataDir, { recursive: true, force: true }) + }) + + // Why: QA saw an upgraded app keep a running service on the old plugin until any pane opened. + it('upgrades an existing install, TUI copy included, so a running service reloads it', () => { + const pluginsDir = join(resolveOpenCodeConfigDirectory(), 'plugins') + mkdirSync(pluginsDir, { recursive: true }) + writeFileSync(join(pluginsDir, PLUGIN), '// plugin from the previous Orca release') + + openCode2HookService.refreshInstalledPlugins() + + expect(readFileSync(join(pluginsDir, PLUGIN), 'utf8')).toBe(getOpenCode2PluginSource()) + expect(readFileSync(join(pluginsDir, TUI_ENTRY), 'utf8')).toBe(getOpenCode2PluginSource()) + }) + + it('never creates an install the user did not already have', () => { + openCode2HookService.refreshInstalledPlugins() + + expect(existsSync(resolveOpenCodeConfigDirectory())).toBe(false) + expect(existsSync(join(userDataDir, 'opencode2-config-overlays'))).toBe(false) + }) + + it('leaves a current install untouched so nothing reloads', () => { + const pluginsDir = join(resolveOpenCodeConfigDirectory(), 'plugins') + mkdirSync(join(pluginsDir, 'orca-opencode2-status-tui'), { recursive: true }) + writeFileSync(join(pluginsDir, PLUGIN), getOpenCode2PluginSource()) + writeFileSync(join(pluginsDir, TUI_ENTRY), getOpenCode2PluginSource()) + const past = new Date('2020-01-01T00:00:00Z') + utimesSync(join(pluginsDir, PLUGIN), past, past) + utimesSync(join(pluginsDir, TUI_ENTRY), past, past) + + openCode2HookService.refreshInstalledPlugins() + + expect(statSync(join(pluginsDir, PLUGIN)).mtimeMs).toBe(past.getTime()) + expect(statSync(join(pluginsDir, TUI_ENTRY)).mtimeMs).toBe(past.getTime()) + }) + + it('upgrades existing source overlays and skips overlays without Orca plugin', () => { + const overlays = join(userDataDir, 'opencode2-config-overlays') + const stale = join(overlays, 'stale', 'plugins') + const foreign = join(overlays, 'foreign', 'plugins') + mkdirSync(stale, { recursive: true }) + mkdirSync(foreign, { recursive: true }) + writeFileSync(join(stale, PLUGIN), '// plugin from the previous Orca release') + + openCode2HookService.refreshInstalledPlugins() + + expect(readFileSync(join(stale, PLUGIN), 'utf8')).toBe(getOpenCode2PluginSource()) + expect(readFileSync(join(stale, TUI_ENTRY), 'utf8')).toBe(getOpenCode2PluginSource()) + expect(existsSync(join(foreign, PLUGIN))).toBe(false) + }) + + it('keeps refreshing other installs when one write fails', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const service = new OpenCodeHookService({ + pluginFileName: PLUGIN, + legacyHooksDir: 'opencode2-hooks', + overlayDir: 'opencode2-config-overlays', + pluginSource: () => '// next release', + installsTuiPlugin: true + }) + const pluginsDir = join(resolveOpenCodeConfigDirectory(), 'plugins') + mkdirSync(pluginsDir, { recursive: true }) + writeFileSync(join(pluginsDir, PLUGIN), '// old') + // A file where the TUI copy's directory belongs makes the config-dir write fail. + writeFileSync(join(pluginsDir, 'orca-opencode2-status-tui'), 'obstruction') + const overlayPlugins = join(userDataDir, 'opencode2-config-overlays', 'a', 'plugins') + mkdirSync(overlayPlugins, { recursive: true }) + writeFileSync(join(overlayPlugins, PLUGIN), '// old') + + service.refreshInstalledPlugins() + + expect(readFileSync(join(overlayPlugins, PLUGIN), 'utf8')).toBe('// next release') + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) +}) diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index 2375cf02fd48..ff124eb0b951 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -129,6 +129,18 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => { const pluginSource = readFileSync(pluginPath, 'utf8') expect(pluginSource).toContain('OrcaOpenCodeStatusPlugin') expect(pluginSource).toContain('messageID: part.messageID') + // Why: OpenCode 2 reports pane lifecycle from each TUI, which loads only plugin directories. + const tuiEntry = join( + resolveOpenCodeConfigDirectory(), + 'plugins', + 'orca-opencode-status-tui', + 'tui.js' + ) + expect(readFileSync(tuiEntry, 'utf8')).toBe(pluginSource) + const past = new Date('2020-01-01T00:00:00Z') + utimesSync(tuiEntry, past, past) + service.buildPtyEnv(daemonSessionId) + expect(statSync(tuiEntry).mtimeMs).toBe(past.getTime()) }) // Why: OpenCode 2 reloads a plugin whose file mtime changed, which restarted status mid-turn. @@ -176,6 +188,24 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => { } ) + // Why: a service that reloads between the two writes must already find the TUI copy and stand down. + it('writes the TUI copy before the server plugin file', () => { + const pluginsDir = join(resolveOpenCodeConfigDirectory(), 'plugins') + const serverPath = join(pluginsDir, 'orca-opencode2-status.js') + const tuiDir = join(pluginsDir, 'orca-opencode2-status-tui') + rmSync(serverPath, { recursive: true, force: true }) + rmSync(tuiDir, { recursive: true, force: true }) + // A directory in the server file's place makes that write fail. + mkdirSync(serverPath, { recursive: true }) + try { + openCode2HookService.buildPtyEnv(daemonSessionId) + expect(existsSync(join(tuiDir, 'tui.js'))).toBe(true) + } finally { + rmSync(serverPath, { recursive: true, force: true }) + rmSync(tuiDir, { recursive: true, force: true }) + } + }) + // Why: #22234 — OpenCode 2 installs under the plain `opencode` name, and its loader // rejects a default export that only has server(). Asserting the emitted *source* is // not enough; the installed file is what the v2 server validates, so load it. @@ -227,6 +257,10 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => { expect(module.default?.id).toBe('orca-opencode-status') expect(module.default?.server).toBeTypeOf('function') expect(module.default?.setup).toBeTypeOf('function') + // A service loading this dir stands down only when the TUI copy sits beside it. + expect( + readFileSync(join(legacyPluginPath, '..', 'orca-opencode-status-tui', 'tui.js'), 'utf8') + ).toBe(getOpenCodePluginSource()) }) it('repairs late and overwritten legacy plugins atomically on the same service', () => { @@ -241,7 +275,10 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => { service.refreshLegacySharedPlugin() expect(readFileSync(path, 'utf8')).toBe(getOpenCodePluginSource()) expect(readFileSync(reader, 'utf8')).toBe(stale) - expect(readdirSync(join(path, '..'))).toEqual(['orca-opencode-status.js']) + expect(readdirSync(join(path, '..')).sort()).toEqual([ + 'orca-opencode-status-tui', + 'orca-opencode-status.js' + ]) } finally { closeSync(reader) } @@ -471,6 +508,21 @@ describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () = expectUserConfigIntact() }) + it('installs the TUI copy in the overlay without mirroring a user dir of the same name', () => { + const userTuiDir = join(userConfigDir, 'plugins', 'orca-opencode-status-tui') + mkdirSync(userTuiDir) + writeFileSync(join(userTuiDir, 'tui.js'), 'USER OWNED') + + const env = new OpenCodeHookService().buildPtyEnv(ptyId, userConfigDir) + + const overlayTui = join(env.OPENCODE_CONFIG_DIR!, 'plugins', 'orca-opencode-status-tui') + expect(lstatSync(overlayTui).isSymbolicLink()).toBe(false) + expect(readFileSync(join(overlayTui, 'tui.js'), 'utf8')).toBe( + readFileSync(join(env.OPENCODE_CONFIG_DIR!, 'plugins', 'orca-opencode-status.js'), 'utf8') + ) + expect(readFileSync(join(userTuiDir, 'tui.js'), 'utf8')).toBe('USER OWNED') + }) + it.skipIf(process.platform === 'win32')( 'does not write through a symlinked plugins/ directory into the user filesystem', () => { diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 74f24577c6ed..88007d05c88b 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -13,15 +13,11 @@ import { } from 'node:fs' import { createHash } from 'node:crypto' import { mirrorEntry, safeRemoveTree } from '../pty/overlay-mirror' -import { getStatusPluginEndpointSource } from './status-plugin-endpoint-source' -import { getStatusPluginRuntimeStateSource } from './status-plugin-runtime-state-source' -import { getStatusPluginMessagePreviewSource } from './status-plugin-message-preview-source' -import { getStatusPluginSessionLineageSource } from './status-plugin-session-lineage-source' -import { getStatusPluginPostSource } from './status-plugin-post-source' -import { getStatusPluginDeliverySource } from './status-plugin-delivery-source' -import { getStatusPluginOwnershipSource } from './status-plugin-ownership-source' -import { getStatusPluginLifecycleSource } from './status-plugin-lifecycle-source' -import { getStatusPluginFactorySource } from './status-plugin-factory-source' +import { + getOpenCode2PluginSource, + getOpenCodeFamilyPluginSource, + getOpenCodePluginSource +} from './status-plugin-module-source' import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' import { getOpenCodeLegacySharedConfigDir, @@ -32,6 +28,12 @@ import { isInstalledOpenCodePluginCurrent, isOverlayOpenCodePluginCurrent } from '../../shared/opencode-installed-plugin' +import { + openCodeTuiPluginDirName, + writeOpenCodeTuiPlugin +} from '../../shared/opencode-tui-plugin-install' + +export { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, getOpenCodePluginSource } const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays' @@ -47,6 +49,8 @@ type OpenCodeHookVariant = { legacyHooksDir: string overlayDir: string pluginSource: () => string + /** Also install the module as an OpenCode 2 TUI plugin (never for forks without one). */ + installsTuiPlugin?: boolean } // Why: session IDs may contain path separators and are hashed downstream; cap pathological input. @@ -59,50 +63,13 @@ function toSafeDirName(id: string): string { return createHash('sha256').update(id).digest('hex').slice(0, 32) } -// Both major versions install as `opencode`; let the loader choose server() or setup(). -export function getOpenCodePluginSource(): string { - return getOpenCodeFamilyPluginSource('/hook/opencode', { - emitSessionStart: true, - emitNextEvents: true, - expectedAgent: 'opencode' - }) -} - -export function getOpenCode2PluginSource(): string { - return getOpenCodeFamilyPluginSource('/hook/opencode2', { - emitSessionStart: true, - emitNextEvents: true - }) -} - -export function getOpenCodeFamilyPluginSource( - hookPathname: string, - options: { - emitSessionStart: boolean - emitNextEvents?: boolean - expectedAgent?: 'opencode' | 'opencode2' - } -): string { - // Why: the plugin posts PTY environment data from OpenCode to the shared hooks server. - return [ - ...getStatusPluginEndpointSource(), - ...getStatusPluginRuntimeStateSource(), - ...getStatusPluginMessagePreviewSource(), - ...getStatusPluginSessionLineageSource(), - ...getStatusPluginPostSource(hookPathname), - ...getStatusPluginDeliverySource(), - ...getStatusPluginOwnershipSource(), - ...getStatusPluginLifecycleSource(), - ...getStatusPluginFactorySource(options) - ].join('\n') -} - // Why: installs the plugin into OpenCode's config discovery path so it POSTs to the shared agent-hooks server, unifying OpenCode status with Claude/Codex/Gemini. export class OpenCodeHookService { private readonly pluginSource: () => string private readonly pluginFileName: string private readonly legacyHooksDir: string private readonly overlayDir: string + private readonly installsTuiPlugin: boolean constructor(variant?: OpenCodeHookVariant | (() => string)) { const config: OpenCodeHookVariant = @@ -117,9 +84,11 @@ export class OpenCodeHookService { pluginFileName: ORCA_OPENCODE_PLUGIN_FILE, legacyHooksDir: OPENCODE_LEGACY_HOOKS_DIR, overlayDir: OPENCODE_OVERLAY_DIR, - pluginSource: getOpenCodePluginSource + pluginSource: getOpenCodePluginSource, + installsTuiPlugin: true }) this.pluginSource = config.pluginSource + this.installsTuiPlugin = config.installsTuiPlugin === true this.pluginFileName = config.pluginFileName this.legacyHooksDir = config.legacyHooksDir this.overlayDir = config.overlayDir @@ -162,10 +131,14 @@ export class OpenCodeHookService { // Why: pre-1.4.209 Orca left a server()-only plugin here that OpenCode 2 rejects. Only helps // processes that load it later; a running OpenCode 2 service keeps its cached module until restarted. refreshLegacySharedPlugin(): void { - const pluginPath = join(this.getSharedConfigDir(), 'plugins', this.pluginFileName) + const pluginsDir = join(this.getSharedConfigDir(), 'plugins') + const pluginPath = join(pluginsDir, this.pluginFileName) try { const source = this.pluginSource() - if (readFileSync(pluginPath, 'utf8') !== source) { + const installed = readFileSync(pluginPath, 'utf8') + // Why: a TUI or service still loading this dir needs the TUI copy too, or the service keeps reporting under its starter pane. + this.writeTuiPlugin(pluginsDir, source) + if (installed !== source) { writeFileAtomically(pluginPath, source) } } catch (error) { @@ -176,6 +149,31 @@ export class OpenCodeHookService { } } + // Why: a running OpenCode 2 service reloads a changed plugin file, so refreshing Orca's existing + // installs at app start upgrades it without waiting for the next pane. Never creates an install. + refreshInstalledPlugins(): void { + this.refreshLegacySharedPlugin() + const overlayRoot = this.getOverlayRoot() + const overlays = existsSync(overlayRoot) + ? readdirSync(overlayRoot).map((name) => join(overlayRoot, name)) + : [] + const configDir = resolveOpenCodeConfigDirectory() + for (const dir of [configDir, ...overlays]) { + if (!existsSync(join(dir, 'plugins', this.pluginFileName))) { + continue + } + try { + if (dir === configDir) { + this.writePluginToConfigDir(dir) + } else { + this.writePluginIntoOverlay(dir) + } + } catch (error) { + console.warn('[OpenCode] Failed to refresh status plugin:', dir, error) + } + } + } + private getOverlayRoot(): string { return join(getAppEnvironment().getPath('userData'), this.overlayDir) } @@ -257,7 +255,11 @@ export class OpenCodeHookService { mkdirSync(overlayPluginsDir, { recursive: true }) for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { // Why: skip a user plugin sharing Orca's filename; mirroring it would let writePluginIntoOverlay clobber the user's file. - if (pluginEntry.name === this.pluginFileName) { + if ( + pluginEntry.name === this.pluginFileName || + (this.installsTuiPlugin && + pluginEntry.name === openCodeTuiPluginDirName(this.pluginFileName)) + ) { continue } mirrorEntry( @@ -283,6 +285,7 @@ export class OpenCodeHookService { mkdirSync(pluginsDir, { recursive: true }) const pluginPath = join(pluginsDir, this.pluginFileName) const source = this.pluginSource() + this.writeTuiPlugin(pluginsDir, source) if (!isOverlayOpenCodePluginCurrent(pluginPath, source)) { try { unlinkSync(pluginPath) @@ -298,10 +301,17 @@ export class OpenCodeHookService { mkdirSync(pluginsDir, { recursive: true }) const pluginPath = join(pluginsDir, this.pluginFileName) const source = this.pluginSource() + this.writeTuiPlugin(pluginsDir, source) if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) { writeFileSync(pluginPath, source) } } + + private writeTuiPlugin(pluginsDir: string, source: string): void { + if (this.installsTuiPlugin) { + writeOpenCodeTuiPlugin(pluginsDir, this.pluginFileName, source) + } + } } export const openCodeHookService = new OpenCodeHookService() @@ -309,7 +319,8 @@ export const openCode2HookService = new OpenCodeHookService({ pluginFileName: 'orca-opencode2-status.js', legacyHooksDir: OPENCODE2_LEGACY_HOOKS_DIR, overlayDir: 'opencode2-config-overlays', - pluginSource: getOpenCode2PluginSource + pluginSource: getOpenCode2PluginSource, + installsTuiPlugin: true }) export const _internals = { getOpenCodePluginSource, diff --git a/src/main/opencode/opencode-session-binder.ts b/src/main/opencode/opencode-session-binder.ts index 9d40ccb278c9..0a693e6e928d 100644 --- a/src/main/opencode/opencode-session-binder.ts +++ b/src/main/opencode/opencode-session-binder.ts @@ -14,12 +14,12 @@ import { readOpenCodeDatabase } from '../ai-vault/session-scanner-opencode-sqlit import { columnExists, tableExists } from '../opencode-usage/schema-helpers' import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { listRegisteredPtys } from '../memory/pty-registry' -import type SyncDatabase from '../sqlite/sync-database' import { isOpenCodeClientProcess, type ProcessIdentityRow } from './opencode-client-sweep' import type { HookListenerState } from '../../shared/agent-hook-listener/listener-state' /** - * Main-process binder feeding the session→pane registry (#21359). + * Main-process binder feeding the session→pane registry (#21359), for OpenCode 1 + * `serve` + `attach` only (see isOpenCodeSharedServerPost). * * Each round: read new sessions from the shared server's SQLite store, * snapshot panes, sweep for live clients, correlate, bind. Everything the @@ -212,15 +212,6 @@ export function runOpenCodeBinderRound(deps: BinderRoundDeps): BinderRoundResult return { ownerships } } -/** True when the v2 session table has every column the binder reads. */ -function canReadSessionV2(db: SyncDatabase): boolean { - return ( - tableExists(db, 'session_v2') && - columnExists(db, 'session_v2', 'directory') && - columnExists(db, 'session_v2', 'time_created') - ) -} - /** * Sessions newer than `cursor`, oldest first. The composite * `(time_created, id)` position means rows sharing a millisecond with the @@ -237,7 +228,9 @@ export function listOpenCodeDbSessions( return readOpenCodeDatabase({ dbPath, read: (db) => { - const table = canReadSessionV2(db) ? 'session_v2' : 'session' + // Why `session` only: OpenCode 1 writes it; OpenCode 2 writes `session_v2`, and its + // posts name their own pane, so its sessions must never bind. + const table = 'session' if ( !tableExists(db, table) || !columnExists(db, table, 'directory') || diff --git a/src/main/opencode/opencode-status-plugin-startup-refresh.test.ts b/src/main/opencode/opencode-status-plugin-startup-refresh.test.ts new file mode 100644 index 000000000000..da588a3f6896 --- /dev/null +++ b/src/main/opencode/opencode-status-plugin-startup-refresh.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { refreshV1, refreshV2 } = vi.hoisted(() => ({ + refreshV1: vi.fn<() => void>(), + refreshV2: vi.fn<() => void>() +})) +vi.mock('./hook-service', () => ({ + openCodeHookService: { refreshInstalledPlugins: refreshV1 }, + openCode2HookService: { refreshInstalledPlugins: refreshV2 } +})) + +import { refreshInstalledOpenCodeStatusPlugins } from './opencode-status-plugin-startup-refresh' + +describe('refreshInstalledOpenCodeStatusPlugins', () => { + beforeEach(() => { + refreshV1.mockClear() + refreshV2.mockClear() + }) + + it('refreshes both variants by default', () => { + refreshInstalledOpenCodeStatusPlugins(null) + expect(refreshV1).toHaveBeenCalledTimes(1) + expect(refreshV2).toHaveBeenCalledTimes(1) + }) + + it('touches nothing while agent status hooks are off', () => { + refreshInstalledOpenCodeStatusPlugins({ agentStatusHooksEnabled: false }) + expect(refreshV1).not.toHaveBeenCalled() + expect(refreshV2).not.toHaveBeenCalled() + }) + + it('skips a disabled variant only', () => { + refreshInstalledOpenCodeStatusPlugins({ disabledTuiAgents: ['opencode2'] }) + expect(refreshV1).toHaveBeenCalledTimes(1) + expect(refreshV2).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/opencode/opencode-status-plugin-startup-refresh.ts b/src/main/opencode/opencode-status-plugin-startup-refresh.ts new file mode 100644 index 000000000000..af88a0666a13 --- /dev/null +++ b/src/main/opencode/opencode-status-plugin-startup-refresh.ts @@ -0,0 +1,16 @@ +import { isAgentStatusHooksEnabledForAgent } from '../../shared/agent-status-hooks-setting' +import type { ManagedHookDetectionSettings } from '../agent-hooks/managed-hook-detection-commands' +import { openCode2HookService, openCodeHookService } from './hook-service' + +// Why: an Orca upgrade must reach an OpenCode 2 service that is already running, which reloads a +// changed plugin file, instead of waiting for the next pane spawn to rewrite it. +export function refreshInstalledOpenCodeStatusPlugins( + settings: ManagedHookDetectionSettings +): void { + if (isAgentStatusHooksEnabledForAgent(settings, 'opencode')) { + openCodeHookService.refreshInstalledPlugins() + } + if (isAgentStatusHooksEnabledForAgent(settings, 'opencode2')) { + openCode2HookService.refreshInstalledPlugins() + } +} diff --git a/src/main/opencode/status-plugin-factory-source.ts b/src/main/opencode/status-plugin-factory-source.ts index 218d15c3e41f..43f063dde812 100644 --- a/src/main/opencode/status-plugin-factory-source.ts +++ b/src/main/opencode/status-plugin-factory-source.ts @@ -1,5 +1,5 @@ import { - getOpenCode2SetupSource, + getOpenCode2ModuleSource, getOpenCode2EventNormalizationSource } from '../opencode2/status-plugin-setup-source' @@ -103,6 +103,7 @@ export function getStatusPluginFactorySource(options: { ' const info = event.properties?.info;', ' if (!info?.id || info.parentID) return;', ' rememberSessionRoot(info.id, info.id);', + ' if (isOpenCodeRunProcess()) return; // a `run` goes Busy at once; its start row only blinks idle', ' await enqueueLifecycle(() =>', ' disposed ? undefined : post("SessionStart", { sessionID: info.id })', ' );', @@ -286,7 +287,7 @@ export function getStatusPluginFactorySource(options: { ' },', ' };', '};', - ...(options.emitNextEvents ? getOpenCode2SetupSource() : []), + ...(options.emitNextEvents ? getOpenCode2ModuleSource(pluginID, expectedAgent) : []), '', '// Why: OpenCode also resolves plugins through the module default export, and that', '// loader rejects the module unless the default exposes `server()` ("must default', diff --git a/src/main/opencode/status-plugin-module-source.ts b/src/main/opencode/status-plugin-module-source.ts new file mode 100644 index 000000000000..34030e3b1bfe --- /dev/null +++ b/src/main/opencode/status-plugin-module-source.ts @@ -0,0 +1,47 @@ +import { getStatusPluginEndpointSource } from './status-plugin-endpoint-source' +import { getStatusPluginRuntimeStateSource } from './status-plugin-runtime-state-source' +import { getStatusPluginMessagePreviewSource } from './status-plugin-message-preview-source' +import { getStatusPluginSessionLineageSource } from './status-plugin-session-lineage-source' +import { getStatusPluginPostSource } from './status-plugin-post-source' +import { getStatusPluginDeliverySource } from './status-plugin-delivery-source' +import { getStatusPluginOwnershipSource } from './status-plugin-ownership-source' +import { getStatusPluginLifecycleSource } from './status-plugin-lifecycle-source' +import { getStatusPluginFactorySource } from './status-plugin-factory-source' + +// Both major versions install as `opencode`; let the loader choose server() or setup(). +export function getOpenCodePluginSource(): string { + return getOpenCodeFamilyPluginSource('/hook/opencode', { + emitSessionStart: true, + emitNextEvents: true, + expectedAgent: 'opencode' + }) +} + +export function getOpenCode2PluginSource(): string { + return getOpenCodeFamilyPluginSource('/hook/opencode2', { + emitSessionStart: true, + emitNextEvents: true + }) +} + +export function getOpenCodeFamilyPluginSource( + hookPathname: string, + options: { + emitSessionStart: boolean + emitNextEvents?: boolean + expectedAgent?: 'opencode' | 'opencode2' + } +): string { + // Why: the plugin posts PTY environment data from OpenCode to the shared hooks server. + return [ + ...getStatusPluginEndpointSource(), + ...getStatusPluginRuntimeStateSource(), + ...getStatusPluginMessagePreviewSource(), + ...getStatusPluginSessionLineageSource(), + ...getStatusPluginPostSource(hookPathname), + ...getStatusPluginDeliverySource(), + ...getStatusPluginOwnershipSource(), + ...getStatusPluginLifecycleSource(), + ...getStatusPluginFactorySource(options) + ].join('\n') +} diff --git a/src/main/opencode/status-plugin-post-source.ts b/src/main/opencode/status-plugin-post-source.ts index 0afb78264684..7c219857bca4 100644 --- a/src/main/opencode/status-plugin-post-source.ts +++ b/src/main/opencode/status-plugin-post-source.ts @@ -16,6 +16,8 @@ export function getStatusPluginPostSource(hookPathname: string): string[] { ' worktreeId: process.env.ORCA_WORKTREE_ID || "",', ' env: coords.env,', ' version: coords.version,', + // opencodeMajor is set only by the OpenCode 2 setup() path, never read from OpenCode's version. + ' ...(reportingOpenCodeMajor ? { opencodeMajor: reportingOpenCodeMajor } : {}),', ' payload: { hook_event_name: hookEventName, ...(extraProperties || {}) },', ' });', ' const controller = new AbortController();', diff --git a/src/main/opencode/status-plugin-runtime-state-source.ts b/src/main/opencode/status-plugin-runtime-state-source.ts index 058ee2cdd076..071385e29cdb 100644 --- a/src/main/opencode/status-plugin-runtime-state-source.ts +++ b/src/main/opencode/status-plugin-runtime-state-source.ts @@ -21,6 +21,8 @@ export function getStatusPluginRuntimeStateSource(): string[] { 'let busyRecoveryUsed = false;', 'let busyRecoveryEndpointKey = "";', 'let stateArrivalRevision = 0;', + '// Why: only OpenCode 2 calls setup(); its posts tell the host to keep its OpenCode 1 binder off them.', + 'let reportingOpenCodeMajor = 0;', '// Why: OpenCode can create directory-scoped factories and concurrent root', '// sessions in one pane; module ownership lets waiting/busy aggregate safely.', 'let nextFactoryID = 0;', @@ -52,6 +54,22 @@ export function getStatusPluginRuntimeStateSource(): string[] { 'let assistantPartFlushTimer = null;', 'let messagePartPostInFlight = null;', 'let deliveredMessagePartFactoryID = null;', - 'let lastAssistantPartPostAt = 0;' + 'let lastAssistantPartPostAt = 0;', + ...getRunProcessSource() ] } + +// Mirrors isOpenCodeRunCommand (src/shared/opencode-headless-command.ts) over this process's argv. +function getRunProcessSource(): string[] { + return String.raw` +function isOpenCodeRunProcess() { + // Why drop a leading path: a compiled binary reports its embedded entry script as argv[1]. + const args = process.argv.slice(1); + if (args.length > 0 && /[\\/]/.test(args[0])) args.shift(); + for (let index = 0; index < args.length; index += 1) { + if (!args[index].startsWith("-")) return args[index] === "run"; + if (args[index] === "--log-level") index += 1; + } + return false; +}`.split('\n') +} diff --git a/src/main/opencode2/status-plugin-setup-source.ts b/src/main/opencode2/status-plugin-setup-source.ts index 7b825056ecd8..ab7c1a11f86b 100644 --- a/src/main/opencode2/status-plugin-setup-source.ts +++ b/src/main/opencode2/status-plugin-setup-source.ts @@ -1,3 +1,15 @@ +import { getOpenCode2TuiSource } from './status-plugin-tui-source' +import { openCodeTuiPluginDirName } from '../../shared/opencode-tui-plugin-install' + +/** The OpenCode 2 entry points (server setup and TUI reporter) plus the constants they share. */ +export function getOpenCode2ModuleSource(pluginID: string, expectedAgent: string): string[] { + return [ + `const ORCA_TUI_PLUGIN_ENTRY = new URL("./${openCodeTuiPluginDirName(`${pluginID}.js`)}/tui.js", import.meta.url);`, + `const ORCA_STATUS_AGENT = "${expectedAgent}";`, + ...getOpenCode2SetupSource() + ] +} + export function getOpenCode2SetupSource(): string[] { return String.raw` // Why: OpenCode owns a form under a session id, and Orca retires a blocker when @@ -8,8 +20,70 @@ export function getOpenCode2SetupSource(): string[] { // session ids; when it does, this set stops matching and those forms block. const NON_SESSION_FORM_OWNERS = new Set(["global"]); +// Why one translation: the TUI reporter builds its Needs input payloads with it, so a blocker +// reaches Orca in the same shape whichever process reported it. +function translateOpenCode2Event(inputType, data) { + let type = inputType; + let properties = data || {}; + if (type === "session.created") { + properties = { info: { ...properties, id: properties.sessionID } }; + } else if (type === "session.execution.started") { + type = "session.status"; + properties = { ...properties, status: { type: "busy" } }; + } else if (type === "session.execution.succeeded" || type === "session.execution.failed" || type === "session.execution.interrupted") { + type = "session.status"; + properties = { ...properties, status: { type: "idle" } }; + } else if (type === "permission.asked") { + properties = { ...properties, permission: properties.action, patterns: properties.resources }; + } else if (type === "form.created") { + const form = properties.form; + // Why: block on every form whose owner is a real session. "metadata" is + // optional in OpenCode's schema and its "kind" is a convention no + // producer is obliged to stamp, so an unknown shape must surface a + // blocker the user can clear rather than vanish while OpenCode waits. + if (!form || NON_SESSION_FORM_OWNERS.has(form.sessionID)) return null; + // A malformed form must not throw: that would kill the subscription. + const fields = Array.isArray(form.fields) ? form.fields : []; + type = "question.asked"; + properties = { + ...form, + questions: fields.map((field) => ({ + header: field.title || form.title, + question: field.description || field.title || form.title, + options: (field.options || []).map((option) => ({ label: option.label || option.value, description: option.description || "" })), + multiple: field.type === "multiselect", + })), + }; + } else if (type === "form.replied" || type === "form.cancelled") { + // A resolution for an ignored form is inert: the blocker key carries the + // form id, so it simply matches nothing. + type = type === "form.replied" ? "question.replied" : "question.rejected"; + properties = { ...properties, requestID: properties.id }; + } else if (type === "session.text.started" || type === "session.text.delta" || type === "session.text.ended") { + type = type.replace("session.", "session.next."); + } + return { type, properties }; +} + +// Why: every OpenCode 2 server runs as a "serve" process (the shared service or a +// --standalone child) whose env names only the pane that spawned it, so each full TUI +// reports its own pane through the TUI copy of this plugin instead. +async function tuiReportsPaneLifecycle() { + if (!process.argv.includes("serve")) return false; + try { + const { statSync } = await import("node:fs"); + // Why: an installer that predates the TUI copy (e.g. an older SSH relay) writes only + // this file; without the TUI copy nothing else would report, so keep reporting. + return statSync(ORCA_TUI_PLUGIN_ENTRY).isFile(); + } catch { + return false; + } +} + async function setupOpenCode2Status(ctx) { + reportingOpenCodeMajor = 2; const noop = async () => {}; + if (isOpenCode2TuiContext(ctx)) return setupOpenCode2Tui(ctx); let hooks; // Why: OpenCode may probe setup() with no context during startup, and the setup // API shape can drift between releases. Never throw from setup — a throw surfaces @@ -17,6 +91,7 @@ async function setupOpenCode2Status(ctx) { // than silently running without status reporting. try { if (!ctx || typeof ctx.session?.hook !== "function" || typeof ctx.event?.subscribe !== "function") return noop; + if (await tuiReportsPaneLifecycle()) return noop; const controller = new AbortController(); // Why the envelope: OpenCode 2's plugin adapter unwraps a single-property // { data } success schema, so ctx.session.get resolves to the bare record — @@ -33,46 +108,9 @@ async function setupOpenCode2Status(ctx) { const consume = async () => { for await (const input of ctx.event.subscribe({ signal: controller.signal })) { if (controller.signal.aborted) break; - let type = input.type; - let properties = input.data; - if (type === "session.created") { - properties = { info: { ...properties, id: properties.sessionID } }; - } else if (type === "session.execution.started") { - type = "session.status"; - properties = { ...properties, status: { type: "busy" } }; - } else if (type === "session.execution.succeeded" || type === "session.execution.failed" || type === "session.execution.interrupted") { - type = "session.status"; - properties = { ...properties, status: { type: "idle" } }; - } else if (type === "permission.asked") { - properties = { ...properties, permission: properties.action, patterns: properties.resources }; - } else if (type === "form.created") { - const form = properties.form; - // Why: block on every form whose owner is a real session. "metadata" is - // optional in OpenCode's schema and its "kind" is a convention no - // producer is obliged to stamp, so an unknown shape must surface a - // blocker the user can clear rather than vanish while OpenCode waits. - if (!form || NON_SESSION_FORM_OWNERS.has(form.sessionID)) continue; - // A malformed form must not throw: that would kill the subscription. - const fields = Array.isArray(form.fields) ? form.fields : []; - type = "question.asked"; - properties = { - ...form, - questions: fields.map((field) => ({ - header: field.title || form.title, - question: field.description || field.title || form.title, - options: (field.options || []).map((option) => ({ label: option.label || option.value, description: option.description || "" })), - multiple: field.type === "multiselect", - })), - }; - } else if (type === "form.replied" || type === "form.cancelled") { - // A resolution for an ignored form is inert: the blocker key carries the - // form id, so it simply matches nothing. - type = type === "form.replied" ? "question.replied" : "question.rejected"; - properties = { ...properties, requestID: properties.id }; - } else if (type === "session.text.started" || type === "session.text.delta" || type === "session.text.ended") { - type = type.replace("session.", "session.next."); - } - await hooks.event({ event: { type, properties } }); + const translated = translateOpenCode2Event(input.type, input.data); + if (!translated) continue; + await hooks.event({ event: translated }); } }; const consuming = consume().catch((error) => { @@ -100,7 +138,9 @@ async function setupOpenCode2Status(ctx) { return noop; } } -`.split('\n') +` + .split('\n') + .concat(getOpenCode2TuiSource()) } export function getOpenCode2EventNormalizationSource(): string[] { diff --git a/src/main/opencode2/status-plugin-tui-source.ts b/src/main/opencode2/status-plugin-tui-source.ts new file mode 100644 index 000000000000..aff056d45de2 --- /dev/null +++ b/src/main/opencode2/status-plugin-tui-source.ts @@ -0,0 +1,281 @@ +/** + * TUI reporter for the OpenCode 2 status plugin. The module Orca writes as a server plugin is + * also installed as a TUI plugin; in a TUI process setup() lands here. The TUI runs in its + * pane's PTY, so every post names the right pane. It derives the pane's level from the TUI's + * own session data (which OpenCode keeps current, re-hydrating it on reconnect) instead of + * inferring it from events, and reuses only the plugin's delivery functions. + */ +export function getOpenCode2TuiSource(): string[] { + return String.raw` +const TUI_TICK_MS = 100; +const TUI_EARLY_ROOTS_MAX = 32; +const TUI_RESOLVED_REQUESTS_MAX = 256; +const TUI_ENDPOINT_CHECK_TICKS = 50; + +function isOpenCode2TuiContext(ctx) { + return typeof ctx?.ui?.router?.current === "function" && typeof ctx?.data?.listen === "function"; +} + +function boundedSet(set, value, max) { + set.delete(value); + set.add(value); + if (set.size > max) set.delete(set.values().next().value); +} + +// Why storage.memory: OpenCode keeps it across plugin hot reloads and drops it when the TUI +// exits, so a reload mid-turn keeps this pane's sessions and what it last reported. +function paneStatusMemory(ctx) { + const initial = { owned: [], last: "idle:", lastRoot: "", started: false }; + if (typeof ctx.storage?.memory === "function") return ctx.storage.memory("pane-status", { initial }); + // Why started: without memory a reload looks like a TUI start, and must not reset the pane. + const local = { ...initial, started: true }; + return [local, (mutate) => mutate(local)]; +} + +async function setupOpenCode2Tui(ctx) { + const noop = async () => {}; + // Why: post() needs this pane's key, so a TUI outside an Orca pane has nothing to report. + if (!process.env.ORCA_PANE_KEY) return noop; + if (process.env.ORCA_OPENCODE_AGENT && process.env.ORCA_OPENCODE_AGENT !== ORCA_STATUS_AGENT) return noop; + // Why: OpenCode 1 loads no plugin directories, but refusing it here keeps a future 1.x + // loader from running a second producer beside the 1.x server plugin. + if (/^1\./.test(String(ctx.app?.version || ""))) return noop; + const data = ctx.data.session; + if (typeof data?.status !== "function" || typeof data.root !== "function") return noop; + let factoryID; + try { + const [memory, setMemory] = paneStatusMemory(ctx); + factoryID = ++nextFactoryID; + activeFactoryIDs.add(factoryID); + let disposed = false; + let lastLevel = null; + let ticks = 0; + // Root sessions this TUI saw start before it owned them: their SessionStart and prompt. + const early = new Map(); + // Why: a permission/form list fetched on reconnect can land after the reply event and restore it. + const resolved = new Set(); + + const rootOf = (sessionID) => data.root(sessionID) || sessionID; + const family = (root) => new Set([root, ...(typeof data.family === "function" ? data.family(root) || [] : [])]); + const running = (root) => [...family(root)].some((id) => data.status(id) === "running"); + const currentRoute = () => { + const route = ctx.ui.router.current(); + return route?.type === "session" && typeof route.sessionID === "string" ? rootOf(route.sessionID) : undefined; + }; + // First open permission, else first form, across the root's family. + const blocker = (root) => { + let form; + for (const member of family(root)) { + const permission = (data.permission?.list?.(member) || []).find((request) => !resolved.has(request.id)); + if (permission) return { request: permission, isPermission: true }; + form ??= (data.form?.list?.(member) || []).find((request) => !resolved.has(request.id)); + } + return form ? { request: form, isPermission: false } : null; + }; + const levelKey = (level) => + level.kind === "waiting" ? "waiting:" + level.blocker.request.id + ":" + level.root : level.kind + ":" + level.root; + + function own(root, owned) { + const seen = early.get(root); + early.delete(root); + if (seen?.created) void enqueueLifecycle(() => post("SessionStart", { sessionID: root })); + if (seen?.prompt) postPrompt(root, seen.prompt); + return [...owned, root]; + } + + // Why derive, not infer: OpenCode's session data is the single copy of running/blocked/lineage + // and self-corrects on reconnect; nothing here latches a start or end event. + function derive() { + const route = currentRoute(); + let owned = [...memory.owned]; + if (route && !owned.includes(route) && running(route)) owned = own(route, owned); + // Why keep a root past navigation: a pane that started a turn must still reach Done + // when the user browses to another session mid-turn. + owned = owned.filter((root) => root === route || running(root)); + if (owned.join("\n") !== memory.owned.join("\n")) setMemory((draft) => { draft.owned = owned; }); + const active = owned.filter(running); + for (const root of active) { + // Why only while running: a blocker the session data kept after its turn ended is stale. + const found = blocker(root); + if (found) return { kind: "waiting", root, blocker: found }; + } + const busy = active.at(-1); + return busy ? { kind: "busy", root: busy } : { kind: "idle", root: memory.lastRoot }; + } + + function publish() { + if (disposed) return; + let level; + try { + level = derive(); + } catch { + // Why: a data read that throws must not kill the listener or the tick. + return; + } + const key = levelKey(level); + if (key === memory.last) return; + setMemory((draft) => { + draft.last = key; + if (level.kind !== "idle") draft.lastRoot = level.root; + }); + lastLevel = level; + // Why the lifecycle queue: posts keep the order the levels were derived in. + void enqueueLifecycle(() => deliver(level, true)); + } + + async function deliver(level, changed) { + // Retires assistant text queued under the previous level (see flushPendingAssistantPart). + if (changed) stateArrivalRevision += 1; + const properties = level.root ? { sessionID: level.root } : {}; + if (level.kind === "waiting") { + const { request, isPermission } = level.blocker; + const translated = isPermission + ? translateOpenCode2Event("permission.asked", request) + : translateOpenCode2Event("form.created", { form: request }); + if (!translated) return; + const hookEventName = isPermission ? "PermissionRequest" : "AskUserQuestion"; + await setAttention(hookEventName, { ...translated.properties, ...properties }, factoryID, request.sessionID); + return; + } + // Why flush first: the done-state preview must show the completed reply. + if (level.kind === "idle") await flushPendingAssistantPart(true); + await setStatus(level.kind, properties, factoryID); + } + + function postPrompt(root, prompt) { + void enqueueLifecycle(() => + postMessagePart({ role: "user", text: capMessagePartText(prompt.text), messageID: prompt.messageID, sessionID: root }, factoryID), + ); + } + + function remember(root, update) { + const seen = { ...(early.get(root) || {}), ...update }; + early.delete(root); + early.set(root, seen); + if (early.size > TUI_EARLY_ROOTS_MAX) early.delete(early.keys().next().value); + } + + // Content only; status comes from derive(). + function observe(event) { + const properties = event.data || {}; + const sessionID = properties.sessionID; + if (event.type === "server.connected") { + // Why: OpenCode re-syncs blockers only for the sessions it displays; an owned session the + // user navigated away from would otherwise keep a request answered while disconnected. + for (const root of memory.owned) { + for (const member of family(root)) { + void data.permission?.sync?.(member)?.catch?.(() => {}); + void data.form?.sync?.(member)?.catch?.(() => {}); + } + } + return; + } + if (event.type === "permission.replied") return boundedSet(resolved, properties.requestID, TUI_RESOLVED_REQUESTS_MAX); + if (event.type === "form.replied" || event.type === "form.cancelled") { + return boundedSet(resolved, properties.id, TUI_RESOLVED_REQUESTS_MAX); + } + if (typeof sessionID !== "string" || !sessionID) return; + if (event.type === "session.deleted") { + early.delete(sessionID); + if (memory.owned.includes(sessionID)) { + setMemory((draft) => { draft.owned = draft.owned.filter((id) => id !== sessionID); }); + } + return; + } + if (event.type === "session.created") { + if (!properties.parentID) remember(sessionID, { created: true }); + return; + } + const root = rootOf(sessionID); + const isOwned = memory.owned.includes(root); + if (event.type === "session.inbox.enqueued") { + // Why TUI-only: the server takes the prompt from session.hook("prompt"), which a TUI lacks. + const item = properties.item; + if (sessionID !== root || item?.type !== "user" || typeof item.payload?.text !== "string" || !item.payload.text) return; + const prompt = { text: item.payload.text, messageID: properties.inboxID }; + if (!isOwned) return remember(root, { prompt }); + if (!memory.last.startsWith("waiting:")) postPrompt(root, prompt); + return; + } + if (event.type === "session.text.ended" && isOwned && sessionID === root && typeof properties.text === "string" && properties.text) { + // Why: Orca reads any MessagePart as Working, which would bury this pane's Needs input. + if (memory.last.startsWith("waiting:")) return; + const part = { role: "assistant", text: properties.text, messageID: properties.assistantMessageID, sessionID: root, factoryID }; + // Why queued: the reply must not overtake this turn's SessionStart, prompt or Busy. + void enqueueLifecycle(() => queueAssistantPart({ ...part, authorityRevision: stateArrivalRevision })); + } + } + + // Why synchronous: OpenCode applies each event to its session data before plugin listeners + // run, so deciding here never trails the data, and no queue of events can build up. + const unsubscribe = ctx.data.listen(({ details } = {}) => { + if (disposed || !details || typeof details.type !== "string") return; + try { + observe(details); + publish(); + } catch { + // A malformed event must not break the listener. + } + }); + // Why a tick: a route change has no event, and a reconnect re-hydrates the data without one. + const tick = setInterval(() => { + try { + publish(); + // Why: an Orca restart moves the hook endpoint; the delivery layer re-posts an unchanged + // level only when asked, which the old event-driven path did on every lifecycle event. + if (++ticks % TUI_ENDPOINT_CHECK_TICKS === 0 && lastLevel && desiredFactoryID === factoryID && deliveredEndpointKey !== hookEndpointKey()) { + const level = lastLevel; + void enqueueLifecycle(() => deliver(level, false)); + } + } catch {} + }, TUI_TICK_MS); + if (tick.unref) tick.unref(); + publish(); + if (!memory.started) { + setMemory((draft) => { draft.started = true; }); + // Why: clears a status an earlier process left on this pane (e.g. a pre-upgrade shared service + // posting another pane's turn here). Connected idle, never a completion; reloads skip it. + if (memory.last === "idle:") { + const route = currentRoute(); + void enqueueLifecycle(() => post("SessionStart", route ? { sessionID: route } : {})); + } + } + return async () => { + try { + disposed = true; + clearInterval(tick); + if (typeof unsubscribe === "function") unsubscribe(); + // Why publish nothing: a hot reload disposes this mid-turn and the next generation + // re-derives from the same memory. Unconfirmed delivery is re-derived by that generation. + await releaseTuiStatusDelivery(factoryID, () => setMemory((draft) => { draft.last = ""; })); + } catch { + // Why: cleanup runs during plugin unload; a throw here also fails the plugin. + } + }; + } catch { + if (factoryID !== undefined) activeFactoryIDs.delete(factoryID); + return noop; + } +} + +// Why queued: levels derived before disposal still post, in order, before the identity retires. +function releaseTuiStatusDelivery(factoryID, forgetUndelivered) { + return enqueueLifecycle(async () => { + disposingFactoryIDs.add(factoryID); + while (messagePartPostInFlight) await messagePartPostInFlight; + if (pendingAssistantPart?.factoryID === factoryID) { + if (assistantPartFlushTimer) clearTimeout(assistantPartFlushTimer); + assistantPartFlushTimer = null; + pendingAssistantPart = null; + } + if (desiredFactoryID === factoryID) { + if (statusDeliveryDirty) forgetUndelivered(); + clearStatusRetry(); + statusRevision += 1; + } + activeFactoryIDs.delete(factoryID); + disposingFactoryIDs.delete(factoryID); + }); +} +`.split('\n') +} diff --git a/src/main/orca-profiles/profile-project-state-file.test.ts b/src/main/orca-profiles/profile-project-state-file.test.ts index 305ba53d8690..6d03ff335e3c 100644 --- a/src/main/orca-profiles/profile-project-state-file.test.ts +++ b/src/main/orca-profiles/profile-project-state-file.test.ts @@ -84,49 +84,6 @@ describe('rebuildRepoBackedProjectState', () => { ) }) - it('picks one predecessor project when several prior rows overlap the same repos', () => { - const sharedIdentity = { - canonicalKey: 'git.example.com/acme/shared', - remoteName: 'origin', - remoteUrl: 'git@git.example.com:acme/shared.git' - } - const rebuilt = rebuildRepoBackedProjectState( - makeState({ - repos: [ - makeRepo({ id: 'r1', path: '/left', gitRemoteIdentity: sharedIdentity }), - makeRepo({ id: 'r2', path: '/right', gitRemoteIdentity: sharedIdentity }) - ], - projects: [ - makeProject({ - id: 'git:git.example.com/acme/left', - sourceRepoIds: ['r1'], - updatedAt: 200, - localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } - }), - makeProject({ - id: 'git:git.example.com/acme/right', - sourceRepoIds: ['r2'], - updatedAt: 100, - localWindowsRuntimePreference: { kind: 'windows-host' } - }) - ], - projectHostSetups: [ - makeSetup({ id: 'r1', projectId: 'git:git.example.com/acme/left', repoId: 'r1' }), - makeSetup({ id: 'r2', projectId: 'git:git.example.com/acme/right', repoId: 'r2' }) - ] - }) - ) - - // Equal repo overlap resolves by newest updatedAt; the loser's preference is never merged in. - expect(rebuilt.projects).toEqual([ - expect.objectContaining({ - id: 'git:git.example.com/acme/shared', - sourceRepoIds: ['r1', 'r2'], - localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } - }) - ]) - }) - it('leaves an unclaimed prior project standing with its own independent setups', () => { const rebuilt = rebuildRepoBackedProjectState( makeState({ diff --git a/src/main/orcad/main-preflight-order.test.ts b/src/main/orcad/main-preflight-order.test.ts index e746be8cc5e6..8a7fc60407fa 100644 --- a/src/main/orcad/main-preflight-order.test.ts +++ b/src/main/orcad/main-preflight-order.test.ts @@ -52,8 +52,11 @@ describe('orcad entry', () => { 'runs the selected disposable probe without starting a server: $flag', async ({ flag, nativeFeatures }) => { vi.spyOn(process, 'argv', 'get').mockReturnValue(['runtime', 'orcad.js', flag, 'nonce']) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub only records the call; nothing reads its never return. + const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) await import('./main') expect(profileProbe).toHaveBeenCalledExactlyOnceWith('nonce', { nativeFeatures }) + await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(0)) expect(order).toEqual([]) } ) diff --git a/src/main/orcad/main.ts b/src/main/orcad/main.ts index 79fc667fd22a..46f98853c023 100644 --- a/src/main/orcad/main.ts +++ b/src/main/orcad/main.ts @@ -39,7 +39,10 @@ try { ) { void runOrcadProfilePreflight(process.argv[3], { nativeFeatures: flag === ORCAD_PROFILE_PREFLIGHT_FLAG - }).catch(failStartup) + }) + // Why exit: the owner reads to EOF, so a lingering native handle must not hold the probe open. + .then(() => process.stdout.write('', () => process.exit(0))) + .catch(failStartup) } else { void preflightBundledOrcadStartup() .then(() => { diff --git a/src/main/orcad/native-host-abi.test.ts b/src/main/orcad/native-host-abi.test.ts index dfa2f7d6a7e8..7ba6fc2ff1f1 100644 --- a/src/main/orcad/native-host-abi.test.ts +++ b/src/main/orcad/native-host-abi.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + hostNodeApiVersion, compareDottedVersions, detectLibcFromReportHeader, detectNativeHostAbi, @@ -141,3 +142,10 @@ describe('detectNativeHostAbi', () => { expect(abi.libc).toBe(process.platform === 'linux' ? abi.libc : 'none') }) }) + +describe('hostNodeApiVersion', () => { + it('reads the N-API level and returns null when the runtime reports none', () => { + expect(hostNodeApiVersion({ ...process.versions, napi: '8' })).toBe(8) + expect(hostNodeApiVersion({ ...process.versions, napi: '' })).toBeNull() + }) +}) diff --git a/src/main/orcad/native-host-abi.ts b/src/main/orcad/native-host-abi.ts index 2335ad3274b9..532409104bd4 100644 --- a/src/main/orcad/native-host-abi.ts +++ b/src/main/orcad/native-host-abi.ts @@ -78,6 +78,14 @@ export function detectNativeHostAbi(): NativeHostAbi { } } +/** Highest Node-API version this runtime supports, or null when it reports none. */ +export function hostNodeApiVersion( + versions: NodeJS.ProcessVersions = process.versions +): number | null { + const napi = Number.parseInt(versions.napi ?? '', 10) + return Number.isInteger(napi) && napi > 0 ? napi : null +} + /** `linux-x64-glibc`, `linux-arm64-musl`, `darwin-arm64`, `win32-x64`. */ export function nativeSlotName(abi: Pick): string { return abi.libc === 'none' diff --git a/src/main/orcad/node-pty-prebuilt-slot.test.ts b/src/main/orcad/node-pty-prebuilt-slot.test.ts index 85aafddc7a18..7ec2eba00e60 100644 --- a/src/main/orcad/node-pty-prebuilt-slot.test.ts +++ b/src/main/orcad/node-pty-prebuilt-slot.test.ts @@ -1,11 +1,13 @@ +import { createHash } from 'node:crypto' import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { installPrebuiltSlot, readPrebuiltSlotManifest, - resolveOrcadPrebuildsDir + resolveOrcadPrebuildsDir, + type PrebuiltSlotEntry } from './node-pty-prebuilt-slot' import type { NativeHostAbi } from './native-host-abi' @@ -25,6 +27,14 @@ const DARWIN_ARM64: NativeHostAbi = { nodeAbi: '127' } +const WIN32_X64: NativeHostAbi = { + platform: 'win32', + arch: 'x64', + libc: 'none', + glibcVersion: null, + nodeAbi: '137' +} + const dirs: string[] = [] const temp = (): string => { const dir = mkdtempSync(join(tmpdir(), 'orcad-slot-')) @@ -38,12 +48,47 @@ afterEach(() => { delete process.env.ORCA_ORCAD_PREBUILDS_DIR }) -const stageSlot = (prebuilds: string, slot: string): void => { - mkdirSync(join(prebuilds, slot), { recursive: true }) - writeFileSync(join(prebuilds, slot, 'pty.node'), 'binary') - writeFileSync(join(prebuilds, slot, 'spawn-helper'), 'helper') +const sha256 = (text: string): string => createHash('sha256').update(text).digest('hex') + +/** Stage `files` under `/` and record them in a schema 2 manifest. */ +const stageSlot = ( + prebuilds: string, + slot: string, + entry: Omit, + files: Record +): void => { + for (const [file, contents] of Object.entries(files)) { + const path = join(prebuilds, slot, ...file.split('/')) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, contents) + } + writeFileSync( + join(prebuilds, 'manifest.json'), + JSON.stringify({ + schemaVersion: 2, + module: 'node-pty', + version: '1.1.0', + napi: 8, + slots: { + [slot]: { + ...entry, + files: Object.fromEntries( + Object.entries(files).map(([file, contents]) => [file, sha256(contents)]) + ) + } + } + }) + ) } +const LINUX_GLIBC_ENTRY = { + platform: 'linux', + arch: 'x64', + libc: 'glibc', + glibc: '2.17', + napi: 8 +} as const + describe('resolveOrcadPrebuildsDir', () => { it('looks beside the running bundle', () => { expect(resolveOrcadPrebuildsDir('/opt/orcad/orcad.js')).toBe(join('/opt/orcad', 'prebuilds')) @@ -59,9 +104,19 @@ describe('installPrebuiltSlot', () => { it('installs the slot binary and spawn-helper into build/Release on macOS', () => { const prebuilds = temp() const nodePtyDir = temp() - stageSlot(prebuilds, 'darwin-arm64') + stageSlot( + prebuilds, + 'darwin-arm64', + { platform: 'darwin', arch: 'arm64', libc: 'none', glibc: null, napi: 8 }, + { 'pty.node': 'binary', 'spawn-helper': 'helper' } + ) - const outcome = installPrebuiltSlot({ abi: DARWIN_ARM64, nodePtyDir, prebuildsDir: prebuilds }) + const outcome = installPrebuiltSlot({ + abi: DARWIN_ARM64, + nodePtyDir, + prebuildsDir: prebuilds, + hostNapi: 10 + }) expect(outcome).toEqual({ installed: true, slot: 'darwin-arm64', spawnHelper: true }) expect(existsSync(join(nodePtyDir, 'build', 'Release', 'pty.node'))).toBe(true) @@ -75,46 +130,183 @@ describe('installPrebuiltSlot', () => { // macOS is what made every Linux orcad boot degraded on spawn_helper_missing (#17844). const prebuilds = temp() const nodePtyDir = temp() - stageSlot(prebuilds, 'linux-x64-glibc') + stageSlot(prebuilds, 'linux-x64-glibc', LINUX_GLIBC_ENTRY, { 'pty.node': 'binary' }) - const outcome = installPrebuiltSlot({ abi: LINUX_GLIBC, nodePtyDir, prebuildsDir: prebuilds }) + const outcome = installPrebuiltSlot({ + abi: LINUX_GLIBC, + nodePtyDir, + prebuildsDir: prebuilds, + hostNapi: 10 + }) expect(outcome).toEqual({ installed: true, slot: 'linux-x64-glibc', spawnHelper: false }) expect(existsSync(join(nodePtyDir, 'build', 'Release', 'pty.node'))).toBe(true) expect(existsSync(join(nodePtyDir, 'build', 'Release', 'spawn-helper'))).toBe(false) }) + it('installs the Windows ConPTY runtime beside conpty.node', () => { + // conpty.node resolves conpty\\conpty.dll relative to its own path; flattening the + // directory would fail every useConptyDll spawn. + const prebuilds = temp() + const nodePtyDir = temp() + stageSlot( + prebuilds, + 'win32-x64', + { platform: 'win32', arch: 'x64', libc: 'none', glibc: null, napi: 8 }, + { + 'conpty.node': 'conpty', + 'conpty_console_list.node': 'list', + 'conpty/conpty.dll': 'dll', + 'conpty/OpenConsole.exe': 'exe' + } + ) + + const outcome = installPrebuiltSlot({ + abi: WIN32_X64, + nodePtyDir, + prebuildsDir: prebuilds, + hostNapi: 10 + }) + + expect(outcome).toEqual({ installed: true, slot: 'win32-x64', spawnHelper: false }) + for (const file of ['conpty.node', 'conpty_console_list.node', 'conpty/conpty.dll']) { + expect(existsSync(join(nodePtyDir, 'build', 'Release', ...file.split('/')))).toBe(true) + } + }) + it('will not load a glibc slot on a musl host', () => { // node-pty's own loader cannot tell these apart; the slot name is the only thing that can. const prebuilds = temp() const nodePtyDir = temp() - stageSlot(prebuilds, 'linux-x64-glibc') + stageSlot(prebuilds, 'linux-x64-glibc', LINUX_GLIBC_ENTRY, { 'pty.node': 'binary' }) const outcome = installPrebuiltSlot({ abi: { ...LINUX_GLIBC, libc: 'musl' }, nodePtyDir, - prebuildsDir: prebuilds + prebuildsDir: prebuilds, + hostNapi: 10 }) expect(outcome).toEqual({ installed: false, slot: 'linux-x64-musl', why: 'no-slot' }) expect(existsSync(join(nodePtyDir, 'build', 'Release', 'pty.node'))).toBe(false) }) - it('refuses an ABI-mismatched matrix instead of installing a binary that cannot load', () => { - // Installing it would turn "no prebuilt for this host" into a loader failure that - // reads as a corrupt install. + it('refuses a glibc build filed under the musl slot name', () => { + // A mislabelled CI run (`--slot=linux-x64-musl` in a glibc container) must not reach + // Alpine's loader: the recorded libc, not the directory name, decides. const prebuilds = temp() const nodePtyDir = temp() - stageSlot(prebuilds, 'linux-x64-glibc') + stageSlot(prebuilds, 'linux-x64-musl', LINUX_GLIBC_ENTRY, { 'pty.node': 'binary' }) + + const outcome = installPrebuiltSlot({ + abi: { ...LINUX_GLIBC, libc: 'musl', glibcVersion: null }, + nodePtyDir, + prebuildsDir: prebuilds, + hostNapi: 10 + }) + + expect(outcome).toMatchObject({ + installed: false, + slot: 'linux-x64-musl', + why: 'libc-mismatch' + }) + expect(existsSync(join(nodePtyDir, 'build', 'Release', 'pty.node'))).toBe(false) + }) + + it('refuses a slot built for another architecture', () => { + const prebuilds = temp() + const nodePtyDir = temp() + stageSlot( + prebuilds, + 'linux-x64-glibc', + { ...LINUX_GLIBC_ENTRY, arch: 'arm64' }, + { 'pty.node': 'binary' } + ) + + expect( + installPrebuiltSlot({ abi: LINUX_GLIBC, nodePtyDir, prebuildsDir: prebuilds, hostNapi: 10 }) + ).toMatchObject({ installed: false, why: 'arch-mismatch' }) + }) + + it('gates on N-API, so a newer Node ABI still installs and an older N-API does not', () => { + // NODE_MODULE_VERSION differs between the pinned Node and a host Node 18; N-API is the + // contract that lets one build serve both. + const prebuilds = temp() + stageSlot(prebuilds, 'linux-x64-glibc', LINUX_GLIBC_ENTRY, { 'pty.node': 'binary' }) + + expect( + installPrebuiltSlot({ + abi: { ...LINUX_GLIBC, nodeAbi: '108' }, + nodePtyDir: temp(), + prebuildsDir: prebuilds, + hostNapi: 8 + }) + ).toMatchObject({ installed: true }) + + const nodePtyDir = temp() + expect( + installPrebuiltSlot({ abi: LINUX_GLIBC, nodePtyDir, prebuildsDir: prebuilds, hostNapi: 7 }) + ).toMatchObject({ installed: false, why: 'napi-unsupported' }) + expect(existsSync(join(nodePtyDir, 'build', 'Release', 'pty.node'))).toBe(false) + }) + + it('refuses a glibc slot on a host older than the slot needs', () => { + const prebuilds = temp() + stageSlot( + prebuilds, + 'linux-x64-glibc', + { ...LINUX_GLIBC_ENTRY, glibc: '2.31' }, + { 'pty.node': 'binary' } + ) + + expect( + installPrebuiltSlot({ + abi: { ...LINUX_GLIBC, glibcVersion: '2.28' }, + nodePtyDir: temp(), + prebuildsDir: prebuilds, + hostNapi: 10 + }) + ).toMatchObject({ installed: false, why: 'glibc-too-old' }) + // An unread version is not evidence of an old glibc. + expect( + installPrebuiltSlot({ + abi: { ...LINUX_GLIBC, glibcVersion: null }, + nodePtyDir: temp(), + prebuildsDir: prebuilds, + hostNapi: 10 + }) + ).toMatchObject({ installed: true }) + }) + + it('refuses a slot whose files do not match the manifest hashes', () => { + const prebuilds = temp() + const nodePtyDir = temp() + stageSlot(prebuilds, 'linux-x64-glibc', LINUX_GLIBC_ENTRY, { 'pty.node': 'binary' }) + writeFileSync(join(prebuilds, 'linux-x64-glibc', 'pty.node'), 'torn') + + expect( + installPrebuiltSlot({ abi: LINUX_GLIBC, nodePtyDir, prebuildsDir: prebuilds, hostNapi: 10 }) + ).toMatchObject({ installed: false, why: 'hash-mismatch' }) + expect(existsSync(join(nodePtyDir, 'build', 'Release', 'pty.node'))).toBe(false) + }) + + it('refuses a matrix with no schema 2 manifest instead of installing unchecked binaries', () => { + const prebuilds = temp() + mkdirSync(join(prebuilds, 'linux-x64-glibc'), { recursive: true }) + writeFileSync(join(prebuilds, 'linux-x64-glibc', 'pty.node'), 'binary') writeFileSync( join(prebuilds, 'manifest.json'), JSON.stringify({ module: 'node-pty', version: '1.1.0', nodeAbi: '115', slots: [] }) ) - const outcome = installPrebuiltSlot({ abi: LINUX_GLIBC, nodePtyDir, prebuildsDir: prebuilds }) - - expect(outcome).toMatchObject({ installed: false, why: 'abi-mismatch' }) - expect(existsSync(join(nodePtyDir, 'build', 'Release', 'pty.node'))).toBe(false) + expect( + installPrebuiltSlot({ + abi: LINUX_GLIBC, + nodePtyDir: temp(), + prebuildsDir: prebuilds, + hostNapi: 10 + }) + ).toMatchObject({ installed: false, why: 'no-manifest' }) }) it('reports a missing prebuilds directory distinctly from a missing slot', () => { @@ -138,4 +330,21 @@ describe('readPrebuiltSlotManifest', () => { writeFileSync(join(prebuilds, 'manifest.json'), JSON.stringify({ version: '1.1.0' })) expect(readPrebuiltSlotManifest(prebuilds)).toBeNull() }) + + it('rejects file paths that would escape build/Release', () => { + const prebuilds = temp() + writeFileSync( + join(prebuilds, 'manifest.json'), + JSON.stringify({ + schemaVersion: 2, + module: 'node-pty', + version: '1.1.0', + napi: 8, + slots: { + 'linux-x64-glibc': { ...LINUX_GLIBC_ENTRY, files: { '../evil.node': sha256('x') } } + } + }) + ) + expect(readPrebuiltSlotManifest(prebuilds)).toBeNull() + }) }) diff --git a/src/main/orcad/node-pty-prebuilt-slot.ts b/src/main/orcad/node-pty-prebuilt-slot.ts index 7dcdebba3da1..ccb7988e4afe 100644 --- a/src/main/orcad/node-pty-prebuilt-slot.ts +++ b/src/main/orcad/node-pty-prebuilt-slot.ts @@ -11,29 +11,63 @@ * The prebuilds themselves are built from the PATCHED source (config/patches/node-pty@1.1.0.patch) * by config/scripts/build-orcad-prebuilds.mjs. An upstream tarball would not do: the patch * carries the `.symver` pins and the `--no-as-needed` libutil/libpthread flags that hold the - * Ubuntu 20.04 / glibc 2.31 floor (docs/reference/linux-glibc-compatibility.md). + * glibc 2.28 server-slot floor (docs/reference/linux-glibc-compatibility.md). + * + * The gate is N-API + libc + arch, not NODE_MODULE_VERSION: the slots are Node-API addons, + * so one build loads on the pinned Node and on any host Node whose N-API level is at least + * the slot's (design D2/D6 rung C). */ +import { createHash } from 'node:crypto' import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import process from 'node:process' +import { z } from 'zod' import { usesNodePtySpawnHelper } from '../../shared/node-pty-spawn-helper' -import { nativeSlotName, type NativeHostAbi } from './native-host-abi' +import { + compareDottedVersions, + hostNodeApiVersion, + nativeSlotName, + type NativeHostAbi +} from './native-host-abi' -export type PrebuiltSlotManifest = { - module: string - version: string - nodeAbi: string - slots: string[] -} +const SHA256 = /^[0-9a-f]{64}$/ +// Why: slot-relative POSIX paths only, so a tampered manifest cannot write outside build/Release. +const SLOT_FILE = /^(?!.*(?:^|\/)\.\.(?:\/|$))[\w.-]+(?:\/[\w.-]+)*$/ + +const PrebuiltSlotEntrySchema = z.object({ + platform: z.string(), + arch: z.string(), + libc: z.enum(['glibc', 'musl', 'none']), + /** Highest strong GLIBC_ symbol version the slot needs; null off glibc. */ + glibc: z.string().nullable(), + napi: z.number().int().positive(), + files: z.record(z.string().regex(SLOT_FILE), z.string().regex(SHA256)) +}) + +const PrebuiltSlotManifestSchema = z.object({ + schemaVersion: z.literal(2), + module: z.string(), + version: z.string(), + napi: z.number().int().positive(), + slots: z.record(z.string(), PrebuiltSlotEntrySchema) +}) + +export type PrebuiltSlotEntry = z.infer +export type PrebuiltSlotManifest = z.infer + +export type PrebuiltSlotRefusal = + | 'no-slot' + | 'no-prebuilds-dir' + | 'no-manifest' + | 'napi-unsupported' + | 'libc-mismatch' + | 'arch-mismatch' + | 'glibc-too-old' + | 'hash-mismatch' export type PrebuiltSlotOutcome = | { installed: true; slot: string; spawnHelper: boolean } - | { - installed: false - slot: string - why: 'no-slot' | 'no-prebuilds-dir' | 'abi-mismatch' - detail?: string - } + | { installed: false; slot: string; why: PrebuiltSlotRefusal; detail?: string } /** * Where a deployment's prebuilds live: beside the bundle that is running. `argv[1]` is @@ -48,39 +82,66 @@ export function resolveOrcadPrebuildsDir(entryScript = process.argv[1]): string } export function readPrebuiltSlotManifest(prebuildsDir: string): PrebuiltSlotManifest | null { + let raw: unknown try { - const parsed = JSON.parse(readFileSync(join(prebuildsDir, 'manifest.json'), 'utf8')) as unknown - if (!parsed || typeof parsed !== 'object') { - return null + raw = JSON.parse(readFileSync(join(prebuildsDir, 'manifest.json'), 'utf8')) + } catch { + return null + } + const parsed = PrebuiltSlotManifestSchema.safeParse(raw) + return parsed.success ? parsed.data : null +} + +/** + * Why each check refuses instead of copying: a binary this host cannot load would replace + * a "no usable prebuilt" diagnosis with a loader failure that reads as a corrupt install. + */ +export function checkPrebuiltSlotEntry( + entry: PrebuiltSlotEntry, + abi: NativeHostAbi, + hostNapi: number | null +): { why: PrebuiltSlotRefusal; detail: string } | null { + if (entry.platform !== abi.platform || entry.arch !== abi.arch) { + return { + why: 'arch-mismatch', + detail: `slot was built for ${entry.platform}/${entry.arch}, this host is ${abi.platform}/${abi.arch}` + } + } + if (entry.libc !== abi.libc) { + return { + why: 'libc-mismatch', + detail: `slot was built against ${entry.libc}, this host runs ${abi.libc}` } - const manifest = parsed as Partial - if (typeof manifest.nodeAbi !== 'string' || typeof manifest.version !== 'string') { - return null + } + if (hostNapi === null || hostNapi < entry.napi) { + return { + why: 'napi-unsupported', + detail: `slot needs N-API ${entry.napi}, this Node supports ${hostNapi ?? 'no N-API version it reports'}` } + } + // An unread host version is not evidence of an old glibc; the load probe decides then. + if (entry.glibc && abi.glibcVersion && compareDottedVersions(abi.glibcVersion, entry.glibc) < 0) { return { - module: typeof manifest.module === 'string' ? manifest.module : 'node-pty', - version: manifest.version, - nodeAbi: manifest.nodeAbi, - slots: Array.isArray(manifest.slots) - ? manifest.slots.filter((s) => typeof s === 'string') - : [] + why: 'glibc-too-old', + detail: `slot needs glibc ${entry.glibc}, this host has ${abi.glibcVersion}` } - } catch { - return null } + return null +} + +function sha256File(path: string): string { + return createHash('sha256').update(readFileSync(path)).digest('hex') } /** - * Copy `//pty.node` (and `spawn-helper`) into node-pty's `build/Release`. - * - * Refuses on an ABI mismatch instead of copying: a binary built for another - * `NODE_MODULE_VERSION` cannot load, and installing it would replace a "no prebuilt" - * diagnosis with a loader failure that reads as a corrupt install. + * Copy every file the manifest lists for this host's slot into node-pty's `build/Release`, + * after checking N-API, libc, arch, glibc and each file's sha256. */ export function installPrebuiltSlot(options: { abi: NativeHostAbi nodePtyDir: string prebuildsDir?: string | null + hostNapi?: number | null }): PrebuiltSlotOutcome { const slot = nativeSlotName(options.abi) const prebuildsDir = options.prebuildsDir ?? resolveOrcadPrebuildsDir() @@ -88,34 +149,50 @@ export function installPrebuiltSlot(options: { return { installed: false, slot, why: 'no-prebuilds-dir' } } const manifest = readPrebuiltSlotManifest(prebuildsDir) - if (manifest && manifest.nodeAbi !== options.abi.nodeAbi) { + if (!manifest) { return { installed: false, slot, - why: 'abi-mismatch', - detail: `shipped prebuilds target Node ABI ${manifest.nodeAbi}, this host runs ABI ${options.abi.nodeAbi}` + why: 'no-manifest', + detail: 'manifest.json is missing or not a schema 2 prebuild manifest' } } - const source = join(prebuildsDir, slot, 'pty.node') - if (!existsSync(source)) { + const entry = manifest.slots[slot] + if (!entry) { return { installed: false, slot, why: 'no-slot' } } + const hostNapi = options.hostNapi === undefined ? hostNodeApiVersion() : options.hostNapi + const refusal = checkPrebuiltSlotEntry(entry, options.abi, hostNapi) + if (refusal) { + return { installed: false, slot, ...refusal } + } + const files = Object.entries(entry.files) + for (const [file, expected] of files) { + const source = join(prebuildsDir, slot, ...file.split('/')) + if (!existsSync(source) || sha256File(source) !== expected) { + return { + installed: false, + slot, + why: 'hash-mismatch', + detail: `${slot}/${file} is missing or does not match its manifest sha256` + } + } + } + const releaseDir = join(options.nodePtyDir, 'build', 'Release') - mkdirSync(releaseDir, { recursive: true }) - copyFileSync(source, join(releaseDir, 'pty.node')) + for (const [file] of files) { + const destination = join(releaseDir, ...file.split('/')) + mkdirSync(dirname(destination), { recursive: true }) + copyFileSync(join(prebuildsDir, slot, ...file.split('/')), destination) + } // Why this matters as much as pty.node: on macOS node-pty posix_spawns // build/Release/spawn-helper. Without it every spawn fails with ENOENT at the moment // a user opens a terminal, long after the "install succeeded" line. - let spawnHelper = false - if (usesNodePtySpawnHelper(options.abi.platform)) { - const helperSource = join(prebuildsDir, slot, 'spawn-helper') - if (existsSync(helperSource)) { - const helperDest = join(releaseDir, 'spawn-helper') - copyFileSync(helperSource, helperDest) - chmodSync(helperDest, 0o755) - spawnHelper = true - } + const spawnHelper = + usesNodePtySpawnHelper(options.abi.platform) && Object.hasOwn(entry.files, 'spawn-helper') + if (spawnHelper) { + chmodSync(join(releaseDir, 'spawn-helper'), 0o755) } return { installed: true, slot, spawnHelper } } diff --git a/src/main/orcad/node-pty-precondition.test.ts b/src/main/orcad/node-pty-precondition.test.ts index 76d842a9456f..c5555bab0586 100644 --- a/src/main/orcad/node-pty-precondition.test.ts +++ b/src/main/orcad/node-pty-precondition.test.ts @@ -1,4 +1,14 @@ -import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' import { spawnSync } from 'node:child_process' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' @@ -11,7 +21,39 @@ import { formatNodePtyPreconditionReport, probeLocalBuildToolchainHints } from './node-pty-precondition' -import { detectNativeHostAbi } from './native-host-abi' +import { detectNativeHostAbi, hostNodeApiVersion, type NativeHostAbi } from './native-host-abi' + +/** A schema 2 manifest listing whatever the test staged in `slot`, for this host. */ +function writeHostSlotManifest(prebuildsDir: string, slot: string, abi: NativeHostAbi): void { + const files = Object.fromEntries( + readdirSync(join(prebuildsDir, slot)).map((file) => [ + file, + createHash('sha256') + .update(readFileSync(join(prebuildsDir, slot, file))) + .digest('hex') + ]) + ) + const napi = hostNodeApiVersion() ?? 8 + writeFileSync( + join(prebuildsDir, 'manifest.json'), + JSON.stringify({ + schemaVersion: 2, + module: 'node-pty', + version: '1.1.0', + napi, + slots: { + [slot]: { + platform: abi.platform, + arch: abi.arch, + libc: abi.libc, + glibc: null, + napi, + files + } + } + }) + ) +} const require = createRequire(import.meta.url) const REAL_NODE_PTY = dirname(require.resolve('node-pty/package.json')) @@ -301,6 +343,7 @@ describe('checkNodePtyPrecondition', () => { if (process.platform !== 'win32') { writeFileSync(join(prebuildsDir, slot, 'spawn-helper'), '#!/bin/sh\nexit 0\n') } + writeHostSlotManifest(prebuildsDir, slot, abi) const verdict = checkNodePtyPrecondition({ nodePtyDir: dir, prebuildsDir }) @@ -327,6 +370,7 @@ describe('checkNodePtyPrecondition', () => { if (process.platform !== 'win32' && existsSync(helper)) { cpSync(helper, join(prebuildsDir, slot, 'spawn-helper')) } + writeHostSlotManifest(prebuildsDir, slot, abi) const verdict = checkNodePtyPrecondition({ nodePtyDir: dir, prebuildsDir }) diff --git a/src/main/orcad/node-pty-precondition.ts b/src/main/orcad/node-pty-precondition.ts index ff41a14cf817..e6c9f19fb68a 100644 --- a/src/main/orcad/node-pty-precondition.ts +++ b/src/main/orcad/node-pty-precondition.ts @@ -357,7 +357,7 @@ export function formatNodePtyPreconditionReport( ? `No shipped prebuilt matches slot ${verdict.slot}.` : verdict.prebuilt.why === 'no-prebuilds-dir' ? 'This install ships no prebuilds directory.' - : `Shipped prebuilds are unusable here: ${verdict.prebuilt.detail ?? 'ABI mismatch'}.` + : `Shipped prebuilds are unusable here: ${verdict.prebuilt.detail ?? verdict.prebuilt.why}.` ) } if (toolchainHints.length > 0) { diff --git a/src/main/orcad/orcad-artifact-identity.ts b/src/main/orcad/orcad-artifact-identity.ts index 0049ef10ffaa..86140acdceb1 100644 --- a/src/main/orcad/orcad-artifact-identity.ts +++ b/src/main/orcad/orcad-artifact-identity.ts @@ -4,19 +4,18 @@ import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { z } from 'zod' import { - ORCAD_BUILD_TARGET_FILENAME, + ORCAD_SERVER_TARGET_FILENAME, ORCAD_VERSION, - orcadArtifactFilenames, - orcadArtifactHashPrefix + orcadArtifactFilenames } from '../../shared/orcad-artifacts' -import { ORCAD_BUN_TARGETS } from '../../shared/orcad-bun-runtime' +import { SERVER_TARGETS } from '../../shared/node-runtime-pin' import { orcadAgentBrowserNativeName } from '../../shared/orcad-agent-browser-name' /** Hash installed bytes in the build's order; a version marker is not proof of delivery. */ export async function readOrcadArtifactIdentity(directory: string): Promise { const target = z - .enum(ORCAD_BUN_TARGETS) - .parse((await readFile(join(directory, ORCAD_BUILD_TARGET_FILENAME), 'utf8')).trim()) + .enum(SERVER_TARGETS) + .parse((await readFile(join(directory, ORCAD_SERVER_TARGET_FILENAME), 'utf8')).trim()) const platform = target.startsWith('win32-') ? 'win32' : target.startsWith('darwin-') @@ -31,7 +30,7 @@ export async function readOrcadArtifactIdentity(directory: string): Promise ({ directory: '', @@ -16,8 +17,12 @@ vi.mock('./orcad-app-paths', () => ({ resolveOrcadInstallRoot: () => fixture.dir vi.mock('../persistence/profile-state/profile-state-runtime-preflight', () => ({ preflightProfileStateRuntime: fixture.sqlite })) -vi.mock('./orcad-bun-native-preflight', () => ({ - preflightOrcadBunNativeRuntime: vi.fn(async () => {}) +vi.mock('./orcad-runtime-native-preflight', () => ({ + preflightOrcadNativeRuntime: vi.fn(async () => {}) +})) +vi.mock('./orcad-bundled-runtime', async (importOriginal) => ({ + ...(await importOriginal()), + isRunningAsBundledOrcadRuntime: () => false })) beforeEach(async () => { @@ -25,7 +30,7 @@ beforeEach(async () => { for (const filename of orcadArtifactFilenames('linux-x64-glibc')) { const path = join(fixture.directory, filename) await mkdir(dirname(path), { recursive: true }) - await writeFile(path, filename === '.build-target' ? 'linux-x64-glibc\n' : filename) + await writeFile(path, filename === '.server-target' ? 'linux-x64-glibc\n' : filename) } vi.spyOn(console, 'log').mockImplementation(() => {}) }) @@ -44,21 +49,21 @@ describe('installed artifact admission', () => { ) }) - it.each(['.build-target', 'node_modules/@parcel/watcher/watcher.node', 'bun-runtime'])( - 'refuses a missing %s as configuration before any profile probe', - async (filename) => { - await rm(join(fixture.directory, filename)) - const error = await runOrcadProfilePreflight(randomUUID()).catch( - (failure: unknown) => failure - ) - expect(resolveOrcadExitCode(error)).toBe(78) - expect(fixture.sqlite).not.toHaveBeenCalled() - expect(console.log).not.toHaveBeenCalled() - } - ) + it.each([ + '.server-target', + 'node_modules/@parcel/watcher/watcher.node', + '.runtime-node', + 'node_modules/node-pty/build/Release/pty.node' + ])('refuses a missing %s as configuration before any profile probe', async (filename) => { + await rm(join(fixture.directory, filename)) + const error = await runOrcadProfilePreflight(randomUUID()).catch((failure: unknown) => failure) + expect(resolveOrcadExitCode(error)).toBe(78) + expect(fixture.sqlite).not.toHaveBeenCalled() + expect(console.log).not.toHaveBeenCalled() + }) it('refuses a malformed target even though all named files exist', async () => { - await writeFile(join(fixture.directory, '.build-target'), 'not-a-runtime-target') + await writeFile(join(fixture.directory, '.server-target'), 'not-a-runtime-target') const error = await runOrcadProfilePreflight(randomUUID()).catch((failure: unknown) => failure) expect(resolveOrcadExitCode(error)).toBe(78) expect(fixture.sqlite).not.toHaveBeenCalled() diff --git a/src/main/orcad/orcad-bundled-runtime.integration.test.ts b/src/main/orcad/orcad-bundled-runtime.integration.test.ts index 9db0d5000c97..07c13f0f3e76 100644 --- a/src/main/orcad/orcad-bundled-runtime.integration.test.ts +++ b/src/main/orcad/orcad-bundled-runtime.integration.test.ts @@ -4,13 +4,20 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { runProcess, spawnProcess } from '../../shared/child-process/run-process' +import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin' +import { orcadNodeRuntimeRelativePath } from '../../shared/orcad-artifacts' import { shellEscape } from '../ssh/ssh-connection-utils' +const TARGET = 'darwin-arm64' +const SHA = NODE_RUNTIME_ASSETS[TARGET].executableSha256 +let root = '' let directory = '' const children = new Set>() beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-handoff-')) + root = await mkdtemp(join(tmpdir(), 'orca-handoff-')) + directory = join(root, 'slot') + await mkdir(directory) await build({ stdin: { contents: ` @@ -55,8 +62,11 @@ beforeEach(async () => { target: 'node18', format: 'cjs' }) - await writeFile(join(directory, '.build-target'), 'darwin-arm64\n') - const runtime = join(directory, 'bun-runtime') + await writeFile(join(directory, '.server-target'), `${TARGET}\n`) + await writeFile(join(directory, '.runtime-node'), `${SHA}\n`) + // A stand-in runtime at the shared-store path; handoff never hashes, it checks the marker. + const runtime = join(directory, ...orcadNodeRuntimeRelativePath(TARGET, SHA)) + await mkdir(join(runtime, '..'), { recursive: true }) await writeFile( runtime, `#!/bin/sh\nORCA_TEST_HANDOFF_CHILD=1 exec ${shellEscape(process.execPath)} "$@"\n` @@ -69,7 +79,7 @@ afterEach(async () => { child.kill('SIGKILL') } children.clear() - await rm(directory, { recursive: true, force: true }) + await rm(root, { recursive: true, force: true }) }) function launch( @@ -110,7 +120,7 @@ function launch( describe.skipIf(process.platform === 'win32')('bundled handoff process lifecycle', () => { it('refuses a partial installation before launching its adjacent runtime', async () => { - await rm(join(directory, '.build-target')) + await rm(join(directory, '.server-target')) const result = await runProcess({ program: process.execPath, args: [join(directory, 'orcad.js')], @@ -175,7 +185,7 @@ describe.skipIf(process.platform === 'win32')('bundled handoff process lifecycle ) it('hands off a symlinked entry to its adjacent runtime', async () => { - const aliases = join(directory, 'aliases') + const aliases = join(root, 'aliases') await mkdir(aliases) const entry = join(aliases, 'orcad.js') await symlink(join(directory, 'orcad.js'), entry) diff --git a/src/main/orcad/orcad-bundled-runtime.test.ts b/src/main/orcad/orcad-bundled-runtime.test.ts index 5541314057e7..e4328fb833b2 100644 --- a/src/main/orcad/orcad-bundled-runtime.test.ts +++ b/src/main/orcad/orcad-bundled-runtime.test.ts @@ -1,15 +1,27 @@ import { EventEmitter } from 'node:events' +import { join, resolve } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { handoffToBundledOrcad } from './orcad-bundled-runtime' -import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' -import { ORCAD_VERSION_FILENAME } from '../../shared/orcad-artifacts' - +import { handoffToBundledOrcad, resolveBundledOrcadSlot } from './orcad-bundled-runtime' +import { NODE_RUNTIME_ASSETS, NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin' +import { + ORCAD_NODE_RUNTIME_MARKER_FILENAME, + ORCAD_SERVER_TARGET_FILENAME, + ORCAD_VERSION_FILENAME +} from '../../shared/orcad-artifacts' + +const TARGET = 'linux-x64-glibc' +const SHA = NODE_RUNTIME_ASSETS[TARGET].executableSha256 const fixture = vi.hoisted(() => ({ exists: vi.fn<(path: string) => boolean>(), + read: vi.fn<(path: string) => string>(), realpath: vi.fn<(path: string) => string>(), spawn: vi.fn() })) -vi.mock('node:fs', () => ({ existsSync: fixture.exists, realpathSync: fixture.realpath })) +vi.mock('node:fs', () => ({ + existsSync: fixture.exists, + readFileSync: fixture.read, + realpathSync: fixture.realpath +})) vi.mock('../../shared/child-process/run-process', () => ({ spawnProcess: fixture.spawn })) class RuntimeChild extends EventEmitter { @@ -26,6 +38,9 @@ beforeEach(() => { oldListeners = new Map(signalNames.map((signal) => [signal, process.rawListeners(signal)])) child = new RuntimeChild() fixture.exists.mockReturnValue(true) + fixture.read.mockImplementation((path) => + path.endsWith(ORCAD_SERVER_TARGET_FILENAME) ? `${TARGET}\n` : `${SHA}\n` + ) fixture.realpath.mockImplementation((path) => path) fixture.spawn.mockReturnValue(child) vi.spyOn(process, 'exit').mockImplementation(() => { @@ -55,39 +70,57 @@ describe('bundled Orca runtime handoff', () => { expect(fixture.spawn).not.toHaveBeenCalled() }) - it('refuses an incomplete slot before starting a process', () => { - fixture.exists.mockImplementation((path) => path.endsWith('.build-target')) + it('refuses a slot whose shared runtime is missing before starting a process', () => { + fixture.exists.mockImplementation((path) => !path.includes(`node-${SHA}`)) expect(() => handoffToBundledOrcad()).toThrow('bundled Orca runtime is missing') expect(fixture.spawn).not.toHaveBeenCalled() }) + it('refuses a slot that names a runtime other than the pinned Node', () => { + fixture.read.mockImplementation((path) => + path.endsWith(ORCAD_SERVER_TARGET_FILENAME) ? `${TARGET}\n` : `${'0'.repeat(64)}\n` + ) + expect(() => handoffToBundledOrcad()).toThrow(`does not name Node ${NODE_RUNTIME_PIN.version}`) + expect(fixture.spawn).not.toHaveBeenCalled() + }) + + it('refuses a slot without its runtime reference', () => { + fixture.exists.mockImplementation((path) => !path.endsWith(ORCAD_NODE_RUNTIME_MARKER_FILENAME)) + expect(() => handoffToBundledOrcad()).toThrow('bundled Orca runtime reference is missing') + expect(fixture.spawn).not.toHaveBeenCalled() + }) + it('refuses a versioned slot missing both its runtime and target marker', () => { fixture.exists.mockImplementation((path) => path.endsWith(ORCAD_VERSION_FILENAME)) expect(() => handoffToBundledOrcad()).toThrow('bundled Orca runtime target is missing') expect(fixture.spawn).not.toHaveBeenCalled() }) - it('refuses a remaining bundled runtime without its target marker', () => { - fixture.exists.mockImplementation((path) => !path.endsWith('.build-target')) + it('refuses a remaining runtime reference without its target marker', () => { + fixture.exists.mockImplementation((path) => !path.endsWith(ORCAD_SERVER_TARGET_FILENAME)) expect(() => handoffToBundledOrcad()).toThrow('bundled Orca runtime target is missing') expect(fixture.realpath).toHaveBeenCalledExactlyOnceWith('/slot/orcad.js') expect(fixture.spawn).not.toHaveBeenCalled() }) it('accepts only the pinned version when already executing the bundled runtime', () => { - fixture.realpath.mockReturnValue('/real/runtime') + fixture.realpath.mockImplementation((path) => + path === '/slot/orcad.js' ? path : '/real/runtime' + ) vi.spyOn(process, 'versions', 'get').mockReturnValue({ ...process.versions, - bun: ORCAD_BUN_VERSION + node: NODE_RUNTIME_PIN.version }) expect(handoffToBundledOrcad()).toBe(false) expect(fixture.spawn).not.toHaveBeenCalled() }) - it('refuses an adjacent runtime that reports the wrong Bun version', () => { - fixture.realpath.mockReturnValue('/real/runtime') - vi.spyOn(process, 'versions', 'get').mockReturnValue({ ...process.versions, bun: '0.0.0' }) - expect(() => handoffToBundledOrcad()).toThrow(`must be Bun ${ORCAD_BUN_VERSION}`) + it('refuses a runtime path that reports another Node version', () => { + fixture.realpath.mockImplementation((path) => + path === '/slot/orcad.js' ? path : '/real/runtime' + ) + vi.spyOn(process, 'versions', 'get').mockReturnValue({ ...process.versions, node: '18.0.0' }) + expect(() => handoffToBundledOrcad()).toThrow(`must be Node ${NODE_RUNTIME_PIN.version}`) }) it.each(['linux', 'darwin', 'win32'] as const)( @@ -96,7 +129,7 @@ describe('bundled Orca runtime handoff', () => { vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) expect(handoffToBundledOrcad()).toBe(true) expect(fixture.spawn).toHaveBeenCalledWith({ - program: expect.stringMatching(/bun-runtime(?:\.exe)?$/), + program: join('/slot', '..', 'runtimes', `node-${SHA}`, 'bin', 'node'), args: ['/slot/orcad.js', '--port', '0'], env: expect.objectContaining({ ORCA_BUNDLED_LAUNCHER_CHANNEL: '1' }), detached: true, @@ -143,12 +176,19 @@ describe('bundled Orca runtime handoff', () => { handoffToBundledOrcad() expect(fixture.spawn).toHaveBeenCalledWith( expect.objectContaining({ - program: expect.stringMatching(/real\/slot\/bun-runtime(?:\.exe)?$/), + program: join('/real/slot', '..', 'runtimes', `node-${SHA}`, 'bin', 'node'), args: ['/real/slot/orcad.js', '--port', '0'] }) ) }) + it('resolves the slot a symlinked entry lives in, as the handoff does', () => { + fixture.realpath.mockImplementation((path) => + path === '/bin/orcad.js' ? '/real/slot/orcad.js' : path + ) + expect(resolveBundledOrcadSlot('/bin/orcad.js')).toBe(resolve('/real/slot')) + }) + it('reports failed spawn as a configuration failure and removes listeners', () => { handoffToBundledOrcad() expect(() => child.emit('error', new Error('ENOENT'))).toThrow('test process exit') diff --git a/src/main/orcad/orcad-bundled-runtime.ts b/src/main/orcad/orcad-bundled-runtime.ts index 05b86387124b..230cc5f326fa 100644 --- a/src/main/orcad/orcad-bundled-runtime.ts +++ b/src/main/orcad/orcad-bundled-runtime.ts @@ -1,41 +1,91 @@ -import { existsSync, realpathSync } from 'node:fs' +import { existsSync, readFileSync, realpathSync } from 'node:fs' import { dirname, join } from 'node:path' import { constants } from 'node:os' import { spawnProcess } from '../../shared/child-process/run-process' +import { resolveOrcadInstallRoot } from './orcad-app-paths' import { - ORCAD_BUILD_TARGET_FILENAME, + ORCAD_NODE_RUNTIME_MARKER_FILENAME, + ORCAD_SERVER_TARGET_FILENAME, ORCAD_VERSION_FILENAME, - orcadBunRuntimeFilename + orcadNodeRuntimeRelativePath } from '../../shared/orcad-artifacts' -import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' +import { + NODE_RUNTIME_ASSETS, + NODE_RUNTIME_PIN, + SERVER_TARGETS, + type ServerTarget +} from '../../shared/node-runtime-pin' export class OrcadBundledRuntimeError extends Error {} export const ORCAD_BUNDLED_LAUNCHER_ENV = 'ORCA_BUNDLED_LAUNCHER_CHANNEL' -/** Keep old Node service commands usable without letting Node open the profile. */ +function isServerTarget(value: string): value is ServerTarget { + return SERVER_TARGETS.some((target) => target === value) +} + +/** + * The pinned Node a packaged slot in `directory` runs on, or null for an unpackaged entry. + * Throws when the slot is packaged but its runtime reference is torn or foreign. + */ +export function resolveBundledOrcadRuntime(directory: string): string | null { + const markerPath = join(directory, ORCAD_NODE_RUNTIME_MARKER_FILENAME) + const targetPath = join(directory, ORCAD_SERVER_TARGET_FILENAME) + const hasMarker = existsSync(markerPath) + const hasTarget = existsSync(targetPath) + if (!hasMarker && !hasTarget && !existsSync(join(directory, ORCAD_VERSION_FILENAME))) { + return null + } + if (!hasTarget) { + throw new OrcadBundledRuntimeError('The bundled Orca runtime target is missing') + } + if (!hasMarker) { + throw new OrcadBundledRuntimeError('The bundled Orca runtime reference is missing') + } + const target = readFileSync(targetPath, 'utf8').trim() + if (!isServerTarget(target)) { + throw new OrcadBundledRuntimeError(`The bundled Orca runtime target is invalid: ${target}`) + } + const executableSha256 = readFileSync(markerPath, 'utf8').trim() + // Why the pin and not only a digest shape: the marker becomes a path segment, and a slot + // naming another runtime was not built by this code. + if (executableSha256 !== NODE_RUNTIME_ASSETS[target].executableSha256) { + throw new OrcadBundledRuntimeError( + `The bundled Orca runtime reference does not name Node ${NODE_RUNTIME_PIN.version}` + ) + } + const runtime = join(directory, ...orcadNodeRuntimeRelativePath(target, executableSha256)) + if (!existsSync(runtime)) { + throw new OrcadBundledRuntimeError('The bundled Orca runtime is missing') + } + return runtime +} + +/** The running entry's real slot, resolved as the handoff does, so a symlinked orcad.js is checked. */ +export function resolveBundledOrcadSlot(script = process.argv[1]): string { + return resolveOrcadInstallRoot(script && realpathSync(script)) +} + +/** True only inside the pinned runtime a packaged slot names. */ +export function isRunningAsBundledOrcadRuntime(directory: string): boolean { + const runtime = resolveBundledOrcadRuntime(directory) + return runtime !== null && realpathSync(process.execPath) === realpathSync(runtime) +} + +/** Keep old Node service commands usable without letting a host Node open the profile. */ export function handoffToBundledOrcad(): boolean { const script = process.argv[1] if (!script) { return false } const entry = realpathSync(script) - const directory = dirname(entry) - const runtime = join(directory, orcadBunRuntimeFilename(process.platform)) - const hasTarget = existsSync(join(directory, ORCAD_BUILD_TARGET_FILENAME)) - const hasRuntime = existsSync(runtime) - if (!hasTarget && !hasRuntime && !existsSync(join(directory, ORCAD_VERSION_FILENAME))) { + const runtime = resolveBundledOrcadRuntime(dirname(entry)) + if (!runtime) { return false } - if (!hasTarget) { - throw new OrcadBundledRuntimeError('The bundled Orca runtime target is missing') - } - if (!hasRuntime) { - throw new OrcadBundledRuntimeError('The bundled Orca runtime is missing') - } if (realpathSync(process.execPath) === realpathSync(runtime)) { - if (process.versions.bun !== ORCAD_BUN_VERSION) { + if (process.versions.node !== NODE_RUNTIME_PIN.version) { throw new OrcadBundledRuntimeError( - `The bundled Orca runtime must be Bun ${ORCAD_BUN_VERSION}` + `The bundled Orca runtime must be Node ${NODE_RUNTIME_PIN.version}` ) } return false @@ -44,7 +94,7 @@ export function handoffToBundledOrcad(): boolean { program: runtime, args: [entry, ...process.argv.slice(2)], env: { ...process.env, [ORCAD_BUNDLED_LAUNCHER_ENV]: '1' }, - // Windows' default child job kills Bun before it can drain on launcher disconnect. + // Windows' default child job kills the runtime before it can drain on launcher disconnect. detached: true, stdio: ['inherit', 'inherit', 'inherit', 'ipc'] }) diff --git a/src/main/orcad/orcad-cross-runtime-daemon-adoption.integration.test.ts b/src/main/orcad/orcad-cross-runtime-daemon-adoption.integration.test.ts new file mode 100644 index 000000000000..9cb238850dcf --- /dev/null +++ b/src/main/orcad/orcad-cross-runtime-daemon-adoption.integration.test.ts @@ -0,0 +1,432 @@ +/** + * Design D7.1 R1/R3/R4 and D7.2: a live terminal survives the orcad runtime swap in both + * directions. The last Bun orcad (ORCA_BUN_ORCAD_SLOT) and this checkout's Node slot (out/orcad) + * are installed side by side as `~/.orca-remote/orcad-/`, launched and stopped with + * the client's own deploy commands, and share one data root. + */ +import { build } from 'esbuild' +import { + copyFileSync, + existsSync, + linkSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + writeFileSync +} from 'node:fs' +import { join, resolve } from 'node:path' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin' +import { + ORCAD_BUILD_TARGET_FILENAME, + ORCAD_INSTALL_COMPLETE_FILENAME, + ORCAD_NODE_RUNTIME_MARKER_FILENAME, + ORCAD_VERSION_FILENAME, + orcadBunRuntimeFilename, + orcadNodeRuntimeRelativePath +} from '../../shared/orcad-artifacts' +import { removeTreeSync } from '../../shared/windows-transient-lock-removal' +import { PROTOCOL_VERSION } from '../daemon/types' +import type { ServeReadiness } from '../server/serve-readiness' +import { + getOrcaProfileIndexPath, + getOrcaProfileStateDatabaseFile +} from '../orca-profiles/profile-storage-paths' +import type { SshConnection } from '../ssh/ssh-connection' +import { emptyOrcadActivationRecord, orcadGcPinnedDirNames } from '../ssh/orcad-activation-record' +import { gcOldOrcadVersions } from '../ssh/orcad-remote-gc' +import { + ORCAD_PID_FILENAME, + ORCAD_READINESS_FILENAME, + orcadLaunchCommand, + parseOrcadReadinessOutput +} from '../ssh/orcad-remote-launch' +import { parseOrcadStopOutcome, stopOrcadCommand } from '../ssh/orcad-remote-process-control' +import { RELAY_REMOTE_DIR } from '../ssh/relay-protocol' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { + skipForMissingInputs, + hostServerTarget, + locatePinnedNodeForTests +} from './orcad-node-slot-fixture' + +// GC runs its real commands, against this machine instead of an SSH host. +const { execMock } = vi.hoisted(() => ({ execMock: vi.fn() })) +vi.mock('../ssh/ssh-relay-deploy-helpers', () => ({ execCommand: execMock })) + +type Runtime = 'Bun' | 'Node' +type Slot = { runtime: Runtime; dir: string; version: string } +type ClientResult = { pid: number; isReattach: boolean; output: boolean } + +const pinnedNode = locatePinnedNodeForTests() +const bunSlotSource = process.env.ORCA_BUN_ORCAD_SLOT + ? resolve(process.env.ORCA_BUN_ORCAD_SLOT) + : null +const nodeSlotSource = resolve('out/orcad') +const posix = process.platform !== 'win32' +const host = getRemoteHostPlatform( + process.platform === 'darwin' + ? process.arch === 'arm64' + ? 'darwin-arm64' + : 'darwin-x64' + : process.arch === 'arm64' + ? 'linux-arm64' + : 'linux-x64' +) +const missing = [ + ...(pinnedNode ? [] : ['the pinned Node (ORCA_PINNED_NODE or out/runtimes)']), + ...(existsSync(join(nodeSlotSource, ORCAD_NODE_RUNTIME_MARKER_FILENAME)) + ? [] + : ['a Node orcad slot in out/orcad (pnpm build:orcad)']), + ...(bunSlotSource && + existsSync(join(bunSlotSource, ORCAD_BUILD_TARGET_FILENAME)) && + existsSync(join(bunSlotSource, orcadBunRuntimeFilename(process.platform))) + ? [] + : ['a Bun orcad slot at ORCA_BUN_ORCAD_SLOT (build-orcad-bun.mjs --out-dir)']) +] + +let root = '' +let client = '' +let backupDriver = '' +const launched = new Set() + +/** Without Vitest's markers: daemon-entry.js does not start its server under VITEST. */ +function hostEnv(): NodeJS.ProcessEnv { + return { + ...Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith('VITEST') && key !== 'NODE_ENV') + ), + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_DISABLE_MACOS_LOGIN_SHELL: '1' + } +} + +function shell(command: string, timeoutMs = 60_000): Promise { + return runProcess({ + program: '/bin/sh', + args: ['-c', command], + env: hostEnv(), + timeoutMs + }).then((result) => { + if (result.code !== 0) { + throw new Error(`shell exited ${result.code}: ${result.stderr}\n${command}`) + } + return result.stdout + }) +} + +function linkOrCopy(from: string, to: string): void { + try { + linkSync(from, to) + } catch { + copyFileSync(from, to) + } +} + +/** Hard-links a slot tree into place: an install copies bytes, and links keep the test cheap. */ +function installTree(source: string, destination: string): void { + mkdirSync(destination, { recursive: true }) + for (const entry of readdirSync(source, { withFileTypes: true })) { + const from = join(source, entry.name) + const to = join(destination, entry.name) + if (entry.isDirectory()) { + installTree(from, to) + } else { + linkOrCopy(from, to) + } + } +} + +function installSlot(caseRoot: string, runtime: Runtime, source: string): Slot { + const version = readFileSync(join(source, ORCAD_VERSION_FILENAME), 'utf8').trim() + const dir = join(caseRoot, RELAY_REMOTE_DIR, `orcad-${version}`) + installTree(source, dir) + writeFileSync(join(dir, ORCAD_INSTALL_COMPLETE_FILENAME), '') + return { runtime, dir, version } +} + +/** Both slots under one `.orca-remote/`, the Node one with its shared `runtimes/` entry. */ +function installSlots(caseRoot: string): { slots: Record; nodeRuntime: string } { + const slots = { + Bun: installSlot(caseRoot, 'Bun', bunSlotSource!), + Node: installSlot(caseRoot, 'Node', nodeSlotSource) + } + const target = hostServerTarget() + const nodeRuntime = join( + slots.Node.dir, + ...orcadNodeRuntimeRelativePath(target, NODE_RUNTIME_ASSETS[target].executableSha256) + ) + mkdirSync(join(nodeRuntime, '..'), { recursive: true }) + linkOrCopy(realpathSync(pinnedNode!), nodeRuntime) + return { slots, nodeRuntime: resolve(nodeRuntime) } +} + +async function launch(slot: Slot, userDataDir: string): Promise { + const pid = Number( + ( + await shell( + orcadLaunchCommand(host, { + remoteInstallDir: slot.dir, + nodePath: pinnedNode!, + fullVersion: slot.version, + userDataDir, + bindHost: '127.0.0.1', + port: 0 + }) + ) + ).trim() + ) + launched.add(pid) + let readiness: ServeReadiness | null = null + await vi.waitFor( + () => { + const parsed = parseOrcadReadinessOutput( + readFileSync(join(slot.dir, ORCAD_READINESS_FILENAME), 'utf8') + ) + if (parsed.state !== 'ready') { + const log = existsSync(join(slot.dir, 'orcad.log')) + ? readFileSync(join(slot.dir, 'orcad.log'), 'utf8') + : '' + throw new Error(`${slot.runtime} orcad not ready (${parsed.state}): ${log.slice(-2000)}`) + } + readiness = parsed.readiness + }, + { timeout: 90_000, interval: 250 } + ) + return readiness! +} + +async function stop(slot: Slot): Promise { + const outcome = parseOrcadStopOutcome( + await shell(stopOrcadCommand(host, slot.dir, { waitSeconds: 30, nodePath: pinnedNode! })) + ) + expect(outcome, `${slot.runtime} orcad stop`).toBe('stopped') +} + +async function daemonClient( + op: 'create' | 'attach' | 'kill', + userDataDir: string, + sessionId: string, + marker: string +): Promise { + const result = await runProcess({ + program: pinnedNode!, + args: [client, op, join(userDataDir, 'daemon'), sessionId, marker, root], + env: hostEnv(), + timeoutMs: 30_000 + }) + expect(result.code, result.stderr).toBe(0) + return JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '') +} + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +async function processCommand(pid: number): Promise { + return (await shell(`ps -o command= -p ${pid}`)).trim() +} + +async function backUpProfile(slot: Slot, runtime: string, userDataDir: string): Promise { + const index = JSON.parse(readFileSync(getOrcaProfileIndexPath(userDataDir), 'utf8')) + const profileId = String(index.activeProfileId) + const result = await runProcess({ + program: runtime, + args: [ + backupDriver, + join(slot.dir, 'profile-state-backup-worker-entry.js'), + getOrcaProfileStateDatabaseFile(profileId, userDataDir), + profileId, + join(userDataDir, `backup-by-${slot.runtime}.db`) + ], + env: hostEnv(), + timeoutMs: 60_000 + }) + expect(result.code, result.stderr).toBe(0) + expect(JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '')).toEqual({ ok: true }) +} + +// Windows has no orcad launch path (POSIX-only, orcad-remote-host-support.ts), so no inputs. +const skip = skipForMissingInputs('cross-runtime', posix ? missing : []) + +describe.skipIf(skip || !posix)('a live terminal across the Bun and Node orcad runtimes', () => { + beforeAll(async () => { + // Short root: macOS caps a unix socket path at 104 bytes and the daemon's lives in the data root. + root = realpathSync(mkdtempSync('/tmp/orca-xrt-')) + client = join(root, 'daemon-client.cjs') + backupDriver = join(root, 'backup-driver.cjs') + execMock.mockImplementation(async (_conn: unknown, command: string) => shell(command)) + writeFileSync( + backupDriver, + // The shipped worker entry, driven the way orcad drives it, under the slot's own runtime. + `const { Worker } = require('node:worker_threads') +const [workerPath, databasePath, profileId, targetPath] = process.argv.slice(2) +const worker = new Worker(workerPath, { workerData: { databasePath, profileId, targetPath } }) +worker.on('message', (message) => console.log(JSON.stringify(message))) +worker.on('error', (error) => { console.error(error); process.exitCode = 1 }) +` + ) + await build({ + stdin: { + // orcad's own daemon client, playing a paired client's terminal. + contents: ` + import { DaemonPtyAdapter } from './src/main/daemon/daemon-pty-adapter' + import { getDaemonPidPath, getDaemonSocketPath, getDaemonTokenPath } from './src/main/daemon/daemon-spawner' + import { setAppEnvironment } from './src/shared/app-environment' + const [op, runtimeDir, sessionId, marker, cwd] = process.argv.slice(2) + setAppEnvironment({ + getPath: () => cwd, getAppPath: () => cwd, getVersion: () => 'test', isPackaged: () => true, + onWillQuit() {}, exit: code => process.exit(code), getAppMetrics: () => [] + }) + const adapter = new DaemonPtyAdapter({ + socketPath: getDaemonSocketPath(runtimeDir), tokenPath: getDaemonTokenPath(runtimeDir), + pidPath: getDaemonPidPath(runtimeDir), profileScope: runtimeDir, runtimeDir + }) + let output = '' + adapter.onData(event => { if (event.id === sessionId) output += event.data }) + const deadline = setTimeout(() => { console.error('timed out; output: ' + output); process.exit(98) }, 20_000) + ;(async () => { + const spawned = await adapter.spawn(op === 'create' + ? { sessionId, cols: 80, rows: 24, cwd, shellOverride: '/bin/sh' } + : { sessionId, cols: 80, rows: 24 }) + if (op === 'kill') { + await adapter.shutdown(spawned.id, { immediate: true }) + } else { + // The echoed command line holds the format, never the expanded marker. + adapter.write(spawned.id, "printf 'ORCA_XRT_%s\\\\n' " + marker + "\\r") + while (!output.includes('ORCA_XRT_' + marker)) await new Promise(r => setTimeout(r, 50)) + } + clearTimeout(deadline) + await adapter.disconnectOnly() + console.log(JSON.stringify({ pid: spawned.pid, isReattach: spawned.isReattach === true, output: output.includes('ORCA_XRT_' + marker) })) + process.exit(0) + })().catch(error => { console.error(error); process.exit(1) }) + `, + resolveDir: process.cwd(), + loader: 'ts' + }, + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node18', + external: ['electron', 'node-pty', '@parcel/watcher', '*.node'], + outfile: client, + logLevel: 'silent' + }) + }) + + afterEach(() => { + for (const pid of launched) { + try { + process.kill(pid, 'SIGKILL') + } catch {} + } + launched.clear() + }) + + afterAll(() => { + removeTreeSync(root) + }) + + it.each([ + ['Bun', 'Node'], + ['Node', 'Bun'] + ] as const)( + '%s orcad hands its daemon and profile to the %s orcad', + async (from: Runtime, to: Runtime) => { + const caseRoot = mkdtempSync(join(root, `${from}-${to}-`)) + const userDataDir = join(caseRoot, 'data') + const { slots, nodeRuntime } = installSlots(caseRoot) + const outgoing = slots[from] + const incoming = slots[to] + const sessionId = `xrt-${from}-${to}` + let daemonPid: number | null = null + try { + const first = await launch(outgoing, userDataDir) + const forked = first.health!.terminalDaemon + expect(forked).toMatchObject({ + state: 'live', + buildVersion: outgoing.version, + entryPath: join(outgoing.dir, 'daemon-entry.js'), + protocolVersion: PROTOCOL_VERSION + }) + daemonPid = forked.pid! + // The daemon runs on the runtime the outgoing slot shipped, not on the test's Node. + const runtimes = { + Bun: join(slots.Bun.dir, orcadBunRuntimeFilename(process.platform)), + Node: nodeRuntime + } + expect(await processCommand(daemonPid)).toContain(runtimes[from]) + + const created = await daemonClient('create', userDataDir, sessionId, 'BEFORE') + expect(created).toMatchObject({ isReattach: false, output: true }) + + await stop(outgoing) + expect(isAlive(daemonPid)).toBe(true) + expect(isAlive(created.pid)).toBe(true) + + const second = await launch(incoming, userDataDir) + // Adopted, not replaced: same daemon, still reporting the outgoing build. + expect(second.health!.terminalDaemon).toMatchObject({ + state: 'live', + ownsFreshSessions: true, + pid: daemonPid, + buildVersion: outgoing.version, + entryPath: join(outgoing.dir, 'daemon-entry.js'), + protocolVersion: PROTOCOL_VERSION + }) + // The incoming runtime opened the outgoing runtime's profile database. + expect(first.health!.profileStateAuthority?.classification).toBe('neither') + expect(second.health!.profileStateAuthority).toMatchObject({ + backend: 'sqlite', + migrated: false + }) + expect(second.health!.profileStateAuthority?.classification).not.toBe('neither') + + const attached = await daemonClient('attach', userDataDir, sessionId, 'AFTER') + expect(attached).toEqual({ pid: created.pid, isReattach: true, output: true }) + + // R4: the slot the live daemon was forked from survives GC; an idle stale slot does not. + const stale = join(caseRoot, RELAY_REMOTE_DIR, 'orcad-0.0.1+dead') + mkdirSync(stale) + writeFileSync(join(stale, ORCAD_INSTALL_COMPLETE_FILENAME), '') + writeFileSync( + join(stale, ORCAD_PID_FILENAME), + readFileSync(join(outgoing.dir, ORCAD_PID_FILENAME)) + ) + const record = { ...emptyOrcadActivationRecord(), active: incoming.version } + expect(orcadGcPinnedDirNames(record)).not.toContain(`orcad-${outgoing.version}`) + await gcOldOrcadVersions({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: execCommand is replaced by a local shell for every connection access. + conn: {} as SshConnection, + host, + remoteHome: caseRoot, + currentDirAbsPath: incoming.dir, + record, + liveDaemonVersion: second.health!.terminalDaemon.buildVersion + }) + expect(existsSync(stale)).toBe(false) + expect(existsSync(join(outgoing.dir, 'daemon-entry.js'))).toBe(true) + + await stop(incoming) + await backUpProfile(incoming, runtimes[to], userDataDir) + + await daemonClient('kill', userDataDir, sessionId, '') + await vi.waitFor(() => expect(isAlive(created.pid)).toBe(false), { timeout: 10_000 }) + } finally { + if (daemonPid && isAlive(daemonPid)) { + process.kill(daemonPid, 'SIGKILL') + } + } + }, + 240_000 + ) +}) diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 02533588e9dd..6c0e9bbad227 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -233,8 +233,10 @@ async function startOrcadRuntime( publish: (summary, subject) => agentHookServer.ingestStructuredStatus(summary, subject), forget: (subject) => agentHookServer.dropStructuredStatus(subject), publishChildWork: (subject, evidence, provider) => - agentHookServer.ingestStructuredChildWork(subject, evidence, provider) + agentHookServer.ingestStructuredChildWork(subject, evidence, provider), + readChildWork: (subject) => agentHookServer.getStructuredChildWorkViews(subject) }, + checkHookAgentPresence: (paneKey) => agentHookServer.checkAgentPresence(paneKey), reconcileAgentStatusForEndedProcess: (paneKeys) => agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), buildAgentHookPtyEnv: () => diff --git a/src/main/orcad/orcad-native-preflight.ts b/src/main/orcad/orcad-native-preflight.ts index ee2f323dbe5c..0f3c2cf89702 100644 --- a/src/main/orcad/orcad-native-preflight.ts +++ b/src/main/orcad/orcad-native-preflight.ts @@ -5,7 +5,6 @@ * testable apart from the detection (what the verdict is). */ import process from 'node:process' -import { canUseBunPty } from '../daemon/pty-subprocess/bun-pty-process-capabilities' import { setRuntimeTerminalUnavailableCause } from '../runtime/native-terminal-availability' import { terminalUnavailableMessage } from '../../shared/runtime-types' import { @@ -38,10 +37,6 @@ export type NativePreflightHooks = { * sentence printed here. */ export function runOrcadNativePreflight(hooks: NativePreflightHooks = {}): boolean { - if (!hooks.check && canUseBunPty()) { - setRuntimeTerminalUnavailableCause(null) - return true - } const check = hooks.check ?? checkNodePtyPrecondition const warn = hooks.warn ?? ((message: string) => console.warn(message)) const fail = hooks.fail ?? ((message: string) => console.error(message)) diff --git a/src/main/orcad/orcad-bun-launcher.integration.test.ts b/src/main/orcad/orcad-node-launcher.integration.test.ts similarity index 83% rename from src/main/orcad/orcad-bun-launcher.integration.test.ts rename to src/main/orcad/orcad-node-launcher.integration.test.ts index 8ee18d53293f..c91bad85ca7c 100644 --- a/src/main/orcad/orcad-bun-launcher.integration.test.ts +++ b/src/main/orcad/orcad-node-launcher.integration.test.ts @@ -1,33 +1,42 @@ import { build } from 'esbuild' -import { existsSync, readFileSync } from 'node:fs' -import { copyFile, mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { readFileSync } from 'node:fs' +import { mkdtemp, readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' +import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { spawnProcess } from '../../shared/child-process/run-process' -import { orcadBunRuntimeFilename } from '../../shared/orcad-artifacts' -import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' +import { NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin' import { removeTreeSync } from '../../shared/windows-transient-lock-removal' +import { + locatePinnedNodeForTests, + skipForMissingInputs, + writeNodeSlotFixture +} from './orcad-node-slot-fixture' -const runtimePath = - process.env.BUN_EXECUTABLE ?? resolve('out/orcad', orcadBunRuntimeFilename(process.platform)) -const nodePath = - process.env.ORCA_TEST_NODE_EXECUTABLE ?? (process.versions.bun ? 'node' : process.execPath) +const pinnedNode = locatePinnedNodeForTests() +// Any Node other than the slot's runtime path plays the service unit's launcher. +const nodePath = process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.execPath let directory = '' +let slotDir = '' +let slotRuntime = '' const children = new Set>() const runtimes = new Set() -describe.skipIf(!existsSync(runtimePath))('real Bun launcher lifecycle', () => { +const skip = skipForMissingInputs( + 'artifact', + pinnedNode ? [] : ['the pinned Node (ORCA_PINNED_NODE or out/runtimes)'] +) + +describe.skipIf(skip)('real pinned-Node launcher lifecycle', () => { beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-bun-launcher-')) - await copyFile(runtimePath, join(directory, orcadBunRuntimeFilename(process.platform))) - await writeFile(join(directory, '.build-target'), `${process.platform}-${process.arch}\n`) + directory = await mkdtemp(join(tmpdir(), 'orca-node-launcher-')) + ;({ slotDir, runtime: slotRuntime } = await writeNodeSlotFixture(directory, pinnedNode!)) await build({ // Record entry before imports without relying on either process's stdout. banner: { js: ` function traceLauncherPhase(phase) { - const runtime = process.versions.bun ? 'bun' : 'node' + const runtime = process.env.ORCA_TEST_ROLE === 'runtime' ? 'runtime' : 'launcher' try { require('node:fs').appendFileSync(process.env.ORCA_TEST_PHASES + '.' + runtime, JSON.stringify({ at: Date.now(), phase, pid: process.pid, runtime, arch: process.arch }) + '\\n') @@ -41,11 +50,12 @@ describe.skipIf(!existsSync(runtimePath))('real Bun launcher lifecycle', () => { }, stdin: { contents: ` - import {handoffToBundledOrcad, OrcadBundledRuntimeError} from './src/main/orcad/orcad-bundled-runtime' + import {handoffToBundledOrcad, isRunningAsBundledOrcadRuntime, OrcadBundledRuntimeError} from './src/main/orcad/orcad-bundled-runtime' import {installOrcadShutdownSignals} from './src/main/orcad/orcad-lifecycle' import {resolveOrcadExitCode} from './src/main/orcad/orcad-exit-code' import {writeFile} from 'node:fs/promises' - if (!process.versions.bun) { + process.env.ORCA_TEST_ROLE = isRunningAsBundledOrcadRuntime(__dirname) ? 'runtime' : 'launcher' + if (process.env.ORCA_TEST_ROLE !== 'runtime') { traceLauncherPhase('before-handoff') if (!handoffToBundledOrcad()) throw new Error('Missing bundled runtime') traceLauncherPhase('after-handoff') @@ -53,7 +63,7 @@ describe.skipIf(!existsSync(runtimePath))('real Bun launcher lifecycle', () => { } else { traceLauncherPhase('before-booting') console.log('booting:' + process.pid) - console.log('runtime:' + process.versions.bun) + console.log('runtime:' + process.versions.node) console.log('channel-env:' + (process.env.ORCA_BUNDLED_LAUNCHER_CHANNEL ?? 'absent')) traceLauncherPhase('booting-written') process.on('exit', code => console.log('runtime-exit:' + code)) @@ -85,7 +95,7 @@ describe.skipIf(!existsSync(runtimePath))('real Bun launcher lifecycle', () => { resolveDir: process.cwd(), loader: 'ts' }, - outfile: join(directory, 'orcad.js'), + outfile: join(slotDir, 'orcad.js'), bundle: true, platform: 'node', target: 'node18', @@ -116,9 +126,7 @@ describe.skipIf(!existsSync(runtimePath))('real Bun launcher lifecycle', () => { failStartup?: boolean } = {} ) { - const runtime = options.direct - ? join(directory, orcadBunRuntimeFilename(process.platform)) - : nodePath + const runtime = options.direct ? slotRuntime : nodePath const startedAt = Date.now() const events: { at: number; event: string }[] = [] const record = (event: string): void => { @@ -128,7 +136,7 @@ describe.skipIf(!existsSync(runtimePath))('real Bun launcher lifecycle', () => { } const child = spawnProcess({ program: options.nohup ? 'nohup' : runtime, - args: [...(options.nohup ? [runtime] : []), join(directory, 'orcad.js')], + args: [...(options.nohup ? [runtime] : []), join(slotDir, 'orcad.js')], env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', @@ -197,7 +205,7 @@ describe.skipIf(!existsSync(runtimePath))('real Bun launcher lifecycle', () => { error: stream.errored?.message, bufferedBytes: stream.readableLength })), - phases: ['node', 'bun'].map((runtime) => { + phases: ['launcher', 'runtime'].map((runtime) => { try { return { runtime, @@ -212,13 +220,13 @@ describe.skipIf(!existsSync(runtimePath))('real Bun launcher lifecycle', () => { } it.each([false, true])( - 'drains Bun after its launcher is killed (startup pending: %s)', + 'drains the pinned runtime after its launcher is killed (startup pending: %s)', async (delay) => { const h = launch({ delay }) await vi.waitFor( () => { expect(h.output()).toContain(delay ? 'booting:' : 'ready') - expect(h.output()).toContain(`runtime:${ORCAD_BUN_VERSION}`) + expect(h.output()).toContain(`runtime:${NODE_RUNTIME_PIN.version}`) expect(h.output()).toContain('channel-env:absent') }, { timeout: 5_000 } @@ -236,7 +244,7 @@ describe.skipIf(!existsSync(runtimePath))('real Bun launcher lifecycle', () => { ) it.skipIf(process.platform === 'win32').each([false, true])( - 'survives nohup hangups and drains on TERM (direct Bun: %s)', + 'survives nohup hangups and drains on TERM (direct runtime: %s)', async (direct) => { const h = launch({ direct, nohup: true }) await vi.waitFor(() => expect(h.output()).toContain('ready'), { timeout: 5_000 }) @@ -286,7 +294,7 @@ describe.skipIf(!existsSync(runtimePath))('real Bun launcher lifecycle', () => { try { await vi.waitFor(() => expect(h.output()).toContain('booting:'), { timeout: 5_000 }) } catch (error) { - console.error('Bun launcher startup diagnostics:', JSON.stringify(h.diagnostics())) + console.error('Node launcher startup diagnostics:', JSON.stringify(h.diagnostics())) throw error } h.child.kill('SIGKILL') diff --git a/src/main/orcad/orcad-node-slot-fixture.ts b/src/main/orcad/orcad-node-slot-fixture.ts new file mode 100644 index 000000000000..007383ab7352 --- /dev/null +++ b/src/main/orcad/orcad-node-slot-fixture.ts @@ -0,0 +1,116 @@ +// Test fixture: a packaged Node slot whose runtime is the real pinned Node, laid out as shipped. +import { existsSync } from 'node:fs' +import { copyFile, link, mkdir, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import type * as NodePty from 'node-pty' +import { join, resolve } from 'node:path' +import { it } from 'vitest' +import { runProcessSync } from '../../shared/child-process/run-process' +import { NODE_RUNTIME_ASSETS, type ServerTarget } from '../../shared/node-runtime-pin' +import { + ORCAD_NODE_PTY_DIR, + ORCAD_NODE_RUNTIME_MARKER_FILENAME, + ORCAD_SERVER_TARGET_FILENAME, + orcadNodeRuntimeRelativePath +} from '../../shared/orcad-artifacts' +import { detectNativeHostAbi, nativeSlotName } from './native-host-abi' + +export function hostServerTarget(): ServerTarget { + const slot = nativeSlotName(detectNativeHostAbi()) + const target = Object.keys(NODE_RUNTIME_ASSETS).find((candidate) => candidate === slot) + if (!target) { + throw new Error(`No pinned Node runtime for host slot ${slot}`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: found among NODE_RUNTIME_ASSETS keys, which are ServerTargets. + return target as ServerTarget +} + +/** The pinned Node the runner (run-node-server-tests.mjs) or `build:orcad` provided, if any. */ +export function locatePinnedNodeForTests(): string | null { + const target = hostServerTarget() + const candidates = [ + process.env.ORCA_PINNED_NODE, + resolve( + 'out/orcad', + ...orcadNodeRuntimeRelativePath(target, NODE_RUNTIME_ASSETS[target].executableSha256) + ) + ] + return ( + candidates.find((candidate): candidate is string => !!candidate && existsSync(candidate)) ?? + null + ) +} + +/** The Bun the last Bun orcad shipped on; only the design D7 cross-runtime gates still run it. */ +export const LAST_BUN_ORCAD_VERSION = '1.4.2' + +/** Bun 1.4.2 (BUN_EXECUTABLE, else `bun` on PATH), the last runtime orcad shipped on, if any. */ +export function locateBunForTests(): string | null { + const candidate = process.env.BUN_EXECUTABLE ?? 'bun' + try { + const result = runProcessSync({ program: candidate, args: ['--version'], timeoutMs: 10_000 }) + return result.code === 0 && result.stdout.trim() === LAST_BUN_ORCAD_VERSION ? candidate : null + } catch { + return null + } +} + +/** Writes `/slot/{.server-target,.runtime-node}` and links the runtime into `/runtimes/`. */ +export async function writeNodeSlotFixture( + root: string, + pinnedNode: string +): Promise<{ slotDir: string; runtime: string }> { + const target = hostServerTarget() + const { executableSha256 } = NODE_RUNTIME_ASSETS[target] + const slotDir = join(root, 'slot') + await mkdir(slotDir, { recursive: true }) + await writeFile(join(slotDir, ORCAD_SERVER_TARGET_FILENAME), `${target}\n`) + await writeFile(join(slotDir, ORCAD_NODE_RUNTIME_MARKER_FILENAME), `${executableSha256}\n`) + const runtime = join(slotDir, ...orcadNodeRuntimeRelativePath(target, executableSha256)) + await mkdir(join(runtime, '..'), { recursive: true }) + // Why a hard link: the runtime is ~120 MB, and a copy per test would dominate the suite. + await link(pinnedNode, runtime).catch(() => copyFile(pinnedNode, runtime)) + return { slotDir, runtime } +} + +/** Comma-separated input groups a lane must have; set by run-node-server-tests.mjs. */ +export const ORCA_REQUIRED_TEST_INPUTS_ENV = 'ORCA_REQUIRED_TEST_INPUTS' +export type RequiredTestInputGroup = 'artifact' | 'cross-runtime' + +function laneRequires(group: RequiredTestInputGroup): boolean { + return (process.env[ORCA_REQUIRED_TEST_INPUTS_ENV] ?? '').split(',').includes(group) +} + +/** + * node-pty as orcad loads it: an artifact lane's packaged slot, else the dev install. + * Why: server lanes install without building node-pty, and Linux has no upstream prebuild. + */ +export async function loadNodePtyForTests(): Promise { + if (!laneRequires('artifact')) { + return import('node-pty') + } + const packaged = resolve('out/orcad', ORCAD_NODE_PTY_DIR) + if (!existsSync(packaged)) { + throw new Error(`Missing artifact test input: out/orcad/${ORCAD_NODE_PTY_DIR}`) + } + return createRequire(import.meta.url)(packaged) +} + +/** + * True when a suite must skip for `missing` inputs. A lane that named `group` as required + * gets a failing test instead, so a missing input can never pass as a silent skip. + */ +export function skipForMissingInputs( + group: RequiredTestInputGroup, + missing: readonly string[] +): boolean { + if (missing.length === 0) { + return false + } + if (laneRequires(group)) { + it(`has its required ${group} inputs`, () => { + throw new Error(`Missing ${group} test inputs: ${missing.join('; ')}`) + }) + } + return true +} diff --git a/src/main/orcad/orcad-packaged-node-pty.integration.test.ts b/src/main/orcad/orcad-packaged-node-pty.integration.test.ts new file mode 100644 index 000000000000..b10bf68b9b6a --- /dev/null +++ b/src/main/orcad/orcad-packaged-node-pty.integration.test.ts @@ -0,0 +1,103 @@ +import { build } from 'esbuild' +import { existsSync } from 'node:fs' +import { mkdtemp, symlink, unlink } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin' +import { ORCAD_NODE_PTY_DIR } from '../../shared/orcad-artifacts' +import { removeTreeSync } from '../../shared/windows-transient-lock-removal' +import { locatePinnedNodeForTests, skipForMissingInputs } from './orcad-node-slot-fixture' + +const packageDir = resolve('out/orcad') +const pinnedNode = locatePinnedNodeForTests() + +const skip = skipForMissingInputs('artifact', [ + ...(pinnedNode ? [] : ['the pinned Node (ORCA_PINNED_NODE or out/runtimes)']), + ...(existsSync(join(packageDir, ORCAD_NODE_PTY_DIR)) ? [] : [`out/orcad/${ORCAD_NODE_PTY_DIR}`]) +]) + +describe.skipIf(skip)('packaged node-pty under the pinned Node', () => { + it('spawns, reattaches, delivers data and retires a shell from the shipped slot only', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-packaged-node-pty-')) + try { + const entry = join(directory, 'local-pty.cjs') + // Why a link and not NODE_PATH: the dynamic ESM import() ignores NODE_PATH. + await symlink(join(packageDir, 'node_modules'), join(directory, 'node_modules'), 'junction') + await build({ + stdin: { + contents: ` + import { LocalPtyProvider } from './src/main/providers/local-pty-provider' + import { setAppEnvironment } from './src/shared/app-environment' + import { getCmdExePath } from './src/shared/windows-batch-spawn' + const loaded = require('fs').realpathSync(require.resolve('node-pty')) + if (!loaded.startsWith(require('fs').realpathSync(${JSON.stringify(packageDir)}))) throw new Error('node-pty resolved outside the package: ' + loaded) + setAppEnvironment({ + getPath: () => process.cwd(), getAppPath: () => process.cwd(), + getVersion: () => 'test', isPackaged: () => true, + onWillQuit() {}, exit: code => process.exit(code), getAppMetrics: () => [] + }) + const provider = new LocalPtyProvider() + let output = '', resolveExit + const exit = new Promise(resolve => { resolveExit = resolve }) + provider.onData(event => { output += event.data }) + provider.onExit(event => resolveExit(event.code)) + const deadline = setTimeout(() => { provider.killAll(); process.exit(98) }, 10_000) + ;(async () => { + const first = await provider.spawn({ + sessionId: 'packaged-node-pty', cols: 80, rows: 24, cwd: process.cwd(), + shellOverride: process.platform === 'win32' ? getCmdExePath() : '/bin/sh' + }) + const again = await provider.spawn({ sessionId: first.id, cols: 100, rows: 30 }) + provider.write(first.id, process.platform === 'win32' + ? 'echo ORCA_PACKAGED_PTY_READY & exit 17\\r' + : 'printf ORCA_PACKAGED_PTY_READY; exit 17\\r') + const code = await exit + clearTimeout(deadline) + console.log(JSON.stringify({ + version: process.versions.node, code, output: output.includes('ORCA_PACKAGED_PTY_READY'), + reattached: again.isReattach === true && again.pid === first.pid, + retired: provider.getPtyProcess(first.id) === undefined + })) + })().catch(error => { clearTimeout(deadline); provider.killAll(); console.error(error); process.exitCode = 1 }) + `, + resolveDir: process.cwd(), + loader: 'ts' + }, + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node18', + external: ['node-pty', 'electron', '@parcel/watcher', '*.node'], + outfile: entry, + logLevel: 'silent' + }) + const result = await runProcess({ + program: pinnedNode!, + args: [entry], + cwd: directory, + env: { + ...process.env, + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_DISABLE_MACOS_LOGIN_SHELL: '1', + ORCA_USER_DATA_PATH: directory + }, + timeoutMs: 15_000, + terminationBarrier: true + }) + expect(result.code, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toEqual({ + version: NODE_RUNTIME_PIN.version, + code: 17, + output: true, + reattached: true, + retired: true + }) + } finally { + // Unlink first so tree removal can never walk into the package it links to. + await unlink(join(directory, 'node_modules')).catch(() => {}) + removeTreeSync(directory) + } + }, 20_000) +}) diff --git a/src/main/orcad/orcad-profile-preflight.test.ts b/src/main/orcad/orcad-profile-preflight.test.ts index 9c51e1a349ea..809f7d839802 100644 --- a/src/main/orcad/orcad-profile-preflight.test.ts +++ b/src/main/orcad/orcad-profile-preflight.test.ts @@ -1,9 +1,10 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ProcessResult, ProcessSpec } from '../../shared/child-process/run-process' -import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' +import { NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin' import { ORCAD_STARTUP_PREFLIGHT_FLAG } from '../../shared/orcad-profile-preflight' import { OrcadBundledRuntimeError } from './orcad-bundled-runtime' +import type * as BundledRuntime from './orcad-bundled-runtime' import { resolveOrcadExitCode } from './orcad-exit-code' import { preflightBundledOrcadStartup, runOrcadProfilePreflight } from './orcad-profile-preflight' @@ -12,16 +13,21 @@ const fixture = vi.hoisted(() => ({ readVersion: vi.fn(), sql: vi.fn(), native: vi.fn(), + bundled: vi.fn<(directory: string) => boolean>(), run: vi.fn<(spec: ProcessSpec) => Promise>() })) +vi.mock('./orcad-bundled-runtime', async (importOriginal) => ({ + ...(await importOriginal()), + isRunningAsBundledOrcadRuntime: fixture.bundled, + resolveBundledOrcadSlot: () => '/slot' +})) vi.mock('./orcad-artifact-identity', () => ({ readOrcadArtifactIdentity: fixture.identity })) -vi.mock('./orcad-app-paths', () => ({ resolveOrcadInstallRoot: () => '/slot' })) vi.mock('node:fs/promises', () => ({ readFile: fixture.readVersion })) vi.mock('../persistence/profile-state/profile-state-runtime-preflight', () => ({ preflightProfileStateRuntime: fixture.sql })) -vi.mock('./orcad-bun-native-preflight', () => ({ - preflightOrcadBunNativeRuntime: fixture.native +vi.mock('./orcad-runtime-native-preflight', () => ({ + preflightOrcadNativeRuntime: fixture.native })) vi.mock('../../shared/child-process/run-process', () => ({ runProcess: fixture.run })) @@ -37,8 +43,8 @@ function readyResult(challenge: string | undefined): ProcessResult { stdout: JSON.stringify({ type: 'orca_profile_state_ready', nonce: challenge, - runtime: 'bun', - runtimeVersion: ORCAD_BUN_VERSION, + runtime: 'node', + runtimeVersion: NODE_RUNTIME_PIN.version, artifactVersion: identity, sqliteVersion: '3.53.2', revision: 1 @@ -49,8 +55,9 @@ function readyResult(challenge: string | undefined): ProcessResult { beforeEach(() => { vi.spyOn(process, 'versions', 'get').mockReturnValue({ ...process.versions, - bun: ORCAD_BUN_VERSION + node: NODE_RUNTIME_PIN.version }) + fixture.bundled.mockReturnValue(true) fixture.identity.mockResolvedValue(identity) fixture.readVersion.mockResolvedValue(`${identity}\n`) fixture.sql.mockResolvedValue({ sqliteVersion: '3.53.2', revision: 1 }) @@ -69,9 +76,10 @@ describe('bundled Orca startup readiness', () => { async (platform) => { vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) await preflightBundledOrcadStartup() + expect(fixture.bundled).toHaveBeenCalledWith('/slot') expect(fixture.run).toHaveBeenCalledOnce() expect(fixture.run).toHaveBeenCalledWith({ - program: join('/slot', platform === 'win32' ? 'bun-runtime.exe' : 'bun-runtime'), + program: process.execPath, args: [join('/slot', 'orcad.js'), ORCAD_STARTUP_PREFLIGHT_FLAG, expect.any(String)], env: expect.objectContaining({ ORCA_BACKGROUND_LAUNCH: '1' }), timeoutMs: 90_000, @@ -83,9 +91,8 @@ describe('bundled Orca startup readiness', () => { } ) - it('leaves legacy Node startup on its existing readiness path', async () => { - const { bun: _bun, ...versions } = process.versions - vi.spyOn(process, 'versions', 'get').mockReturnValue(versions) + it('leaves an unpackaged or host-Node start on its existing readiness path', async () => { + fixture.bundled.mockReturnValue(false) await preflightBundledOrcadStartup() expect(fixture.identity).not.toHaveBeenCalled() expect(fixture.run).not.toHaveBeenCalled() diff --git a/src/main/orcad/orcad-profile-preflight.ts b/src/main/orcad/orcad-profile-preflight.ts index eeb5bb611dd9..c299e0ca0229 100644 --- a/src/main/orcad/orcad-profile-preflight.ts +++ b/src/main/orcad/orcad-profile-preflight.ts @@ -11,24 +11,27 @@ import { type OrcadProfilePreflightResponse } from '../../shared/orcad-profile-preflight' import { readOrcadArtifactIdentity } from './orcad-artifact-identity' -import { resolveOrcadInstallRoot } from './orcad-app-paths' -import { ORCAD_VERSION_FILENAME, orcadBunRuntimeFilename } from '../../shared/orcad-artifacts' -import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' +import { ORCAD_VERSION_FILENAME } from '../../shared/orcad-artifacts' +import { ORCAD_NODE_RUNTIME_IDENTITY } from '../../shared/orcad-node-runtime-identity' import { runProcess } from '../../shared/child-process/run-process' -import { preflightOrcadBunNativeRuntime } from './orcad-bun-native-preflight' -import { OrcadBundledRuntimeError } from './orcad-bundled-runtime' +import { preflightOrcadNativeRuntime } from './orcad-runtime-native-preflight' +import { + isRunningAsBundledOrcadRuntime, + OrcadBundledRuntimeError, + resolveBundledOrcadSlot +} from './orcad-bundled-runtime' /** Check every packaged start before a profile index, data-root lock or import is touched. */ export async function preflightBundledOrcadStartup(): Promise { - if (!process.versions.bun) { + const directory = resolveBundledOrcadSlot() + if (!isRunningAsBundledOrcadRuntime(directory)) { return } - const directory = resolveOrcadInstallRoot() const identity = await readInstalledVersion(directory) const nonce = randomUUID() // Keep disposable SQLite ownership and native state out of the serving process. const result = await runProcess({ - program: join(directory, orcadBunRuntimeFilename(process.platform)), + program: process.execPath, args: [join(directory, 'orcad.js'), ORCAD_STARTUP_PREFLIGHT_FLAG, nonce], env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, timeoutMs: ORCAD_PROFILE_PREFLIGHT_TIMEOUT_MS, @@ -40,7 +43,7 @@ export async function preflightBundledOrcadStartup(): Promise { throw new Failure(`The bundled Orca runtime failed readiness: ${result.stderr}`) } try { - parseOrcadProfilePreflight(result.stdout, nonce, ORCAD_BUN_VERSION, identity) + parseOrcadProfilePreflight(result.stdout, nonce, ORCAD_NODE_RUNTIME_IDENTITY, identity) } catch (cause) { throw new OrcadBundledRuntimeError('The bundled runtime returned invalid readiness identity', { cause @@ -54,23 +57,25 @@ export async function runOrcadProfilePreflight( options: { nativeFeatures?: boolean } = {} ): Promise { const checkedNonce = z.string().uuid().parse(nonce) + const directory = resolveBundledOrcadSlot() let artifactVersion: string try { - artifactVersion = await readOrcadArtifactIdentity(resolveOrcadInstallRoot()) + artifactVersion = await readOrcadArtifactIdentity(directory) } catch (cause) { throw new OrcadBundledRuntimeError('The bundled Orca artifacts are incomplete or altered', { cause }) } const result = await preflightProfileStateRuntime() - if (process.versions.bun) { - await preflightOrcadBunNativeRuntime(options) + // Why only inside the pinned runtime: a host Node rollback launcher never serves this slot. + if (isRunningAsBundledOrcadRuntime(directory)) { + await preflightOrcadNativeRuntime(options) } const response: OrcadProfilePreflightResponse = { type: 'orca_profile_state_ready', nonce: checkedNonce, - runtime: process.versions.bun ? 'bun' : 'node', - runtimeVersion: process.versions.bun ?? process.versions.node, + runtime: 'node', + runtimeVersion: process.versions.node, artifactVersion, ...result } diff --git a/src/main/orcad/orcad-bun-native-preflight.test.ts b/src/main/orcad/orcad-runtime-native-preflight.test.ts similarity index 87% rename from src/main/orcad/orcad-bun-native-preflight.test.ts rename to src/main/orcad/orcad-runtime-native-preflight.test.ts index 3e6df8402c1f..d53ed523dc24 100644 --- a/src/main/orcad/orcad-bun-native-preflight.test.ts +++ b/src/main/orcad/orcad-runtime-native-preflight.test.ts @@ -4,7 +4,7 @@ import type { WatcherProcessCallback, WatcherProcessHooks } from '../ipc/parcel-watcher-process-subscription' -import { preflightOrcadBunNativeRuntime } from './orcad-bun-native-preflight' +import { preflightOrcadNativeRuntime } from './orcad-runtime-native-preflight' const fixture = vi.hoisted(() => ({ temp: vi.fn(), @@ -64,7 +64,7 @@ describe('bundled native readiness', () => { it('keeps runtime startup independent of PTY or watcher probe availability', async () => { fixture.pty.mockRejectedValue(new Error('PTY spawn health check timed out')) fixture.subscribe.mockRejectedValue(new Error('ENOSPC: watch limit reached')) - await preflightOrcadBunNativeRuntime({ nativeFeatures: false }) + await preflightOrcadNativeRuntime({ nativeFeatures: false }) expect(fixture.pty).not.toHaveBeenCalled() expect(fixture.subscribe).not.toHaveBeenCalled() }) @@ -72,20 +72,18 @@ describe('bundled native readiness', () => { it('still requires Windows ownership support on normal startup', async () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') fixture.startTime.mockReturnValue(false) - await expect(preflightOrcadBunNativeRuntime({ nativeFeatures: false })).rejects.toThrow( + await expect(preflightOrcadNativeRuntime({ nativeFeatures: false })).rejects.toThrow( 'Windows process table' ) }) it('does not admit a failed PTY in explicit qualification', async () => { fixture.pty.mockRejectedValue(new Error('PTY spawn health check timed out')) - await expect(preflightOrcadBunNativeRuntime()).rejects.toThrow( - 'PTY spawn health check timed out' - ) + await expect(preflightOrcadNativeRuntime()).rejects.toThrow('PTY spawn health check timed out') }) it('awaits actual watcher delivery and unsubscribe before disposing temporary state', async () => { - await preflightOrcadBunNativeRuntime() + await preflightOrcadNativeRuntime() expect(fixture.pty).toHaveBeenCalledOnce() expect(fixture.unsubscribe).toHaveBeenCalledOnce() expect(fixture.dispose).toHaveBeenCalledOnce() @@ -107,7 +105,7 @@ describe('bundled native readiness', () => { }) }) ) - const readiness = preflightOrcadBunNativeRuntime() + const readiness = preflightOrcadNativeRuntime() const rejected = expect(readiness).rejects.toThrow('readiness timed out') await vi.advanceTimersByTimeAsync(5_000) await rejected @@ -123,14 +121,14 @@ describe('bundled native readiness', () => { return { unsubscribe: fixture.unsubscribe } } ) - await expect(preflightOrcadBunNativeRuntime()).rejects.toThrow('native watcher failed') + await expect(preflightOrcadNativeRuntime()).rejects.toThrow('native watcher failed') expect(fixture.unsubscribe).toHaveBeenCalledOnce() expect(fixture.dispose).toHaveBeenCalledOnce() }) it('still disposes temporary state when unsubscribe fails', async () => { fixture.unsubscribe.mockRejectedValue(new Error('watcher did not exit')) - await expect(preflightOrcadBunNativeRuntime()).rejects.toThrow('watcher did not exit') + await expect(preflightOrcadNativeRuntime()).rejects.toThrow('watcher did not exit') expect(fixture.dispose).toHaveBeenCalledOnce() expect(fixture.remove).toHaveBeenCalledOnce() }) @@ -148,7 +146,7 @@ describe('bundled native readiness', () => { if (reason === 'invalid-self-row') { fixture.rows.mockResolvedValue([{ pid: process.pid }]) } - await expect(preflightOrcadBunNativeRuntime()).rejects.toThrow('Windows process table') + await expect(preflightOrcadNativeRuntime()).rejects.toThrow('Windows process table') expect(fixture.pty).not.toHaveBeenCalled() if (reason !== 'invalid-self-row') { expect(fixture.rows).not.toHaveBeenCalled() @@ -158,7 +156,7 @@ describe('bundled native readiness', () => { it('reads a fresh self identity on Windows before qualifying the PTY', async () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') - await preflightOrcadBunNativeRuntime() + await preflightOrcadNativeRuntime() expect(fixture.rows).toHaveBeenCalledOnce() expect(fixture.pty).toHaveBeenCalledOnce() }) diff --git a/src/main/orcad/orcad-bun-native-preflight.ts b/src/main/orcad/orcad-runtime-native-preflight.ts similarity index 96% rename from src/main/orcad/orcad-bun-native-preflight.ts rename to src/main/orcad/orcad-runtime-native-preflight.ts index 163f6ce29877..0510bc3c15d1 100644 --- a/src/main/orcad/orcad-bun-native-preflight.ts +++ b/src/main/orcad/orcad-runtime-native-preflight.ts @@ -12,7 +12,7 @@ import { } from '../windows/windows-process-table' /** The candidate process owns disposable PTY and watcher probes before it touches user state. */ -export async function preflightOrcadBunNativeRuntime( +export async function preflightOrcadNativeRuntime( options: { nativeFeatures?: boolean } = {} ): Promise { if (process.platform === 'win32') { @@ -41,7 +41,7 @@ export async function preflightOrcadBunNativeRuntime( // A native callback can fail while subscribe is pending. void delivered.catch(() => {}) timer = setTimeout(() => { - const error = new Error('Bun file watcher readiness timed out') + const error = new Error('Bundled file watcher readiness timed out') cancellation.abort(error) rejectDelivery(error) }, 5_000) diff --git a/src/main/persistence-initial-load.test.ts b/src/main/persistence-initial-load.test.ts index 13beae7932c7..a539d5efa193 100644 --- a/src/main/persistence-initial-load.test.ts +++ b/src/main/persistence-initial-load.test.ts @@ -410,68 +410,6 @@ describe('Store', () => { ).toBe(upstreamProjectId) }) - it('picks one predecessor project when several prior rows overlap the same repos', async () => { - const sharedIdentity = { - canonicalKey: 'git.example.com/acme/shared', - remoteName: 'origin', - remoteUrl: 'git@git.example.com:acme/shared.git' - } - writeDataFile({ - ...getDefaultPersistedState(testState.dir), - repos: [ - makeRepo({ - id: 'r1', - path: '/left', - displayName: 'Left', - gitRemoteIdentity: sharedIdentity - }), - makeRepo({ - id: 'r2', - path: '/right', - displayName: 'Right', - gitRemoteIdentity: sharedIdentity - }) - ], - projects: [ - makeProject({ - id: 'git:git.example.com/acme/left', - sourceRepoIds: ['r1'], - updatedAt: 200, - localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } - }), - makeProject({ - id: 'git:git.example.com/acme/right', - sourceRepoIds: ['r2'], - updatedAt: 100, - localWindowsRuntimePreference: { kind: 'windows-host' } - }) - ], - projectHostSetups: [ - makeProjectHostSetup({ - id: 'r1', - projectId: 'git:git.example.com/acme/left', - repoId: 'r1' - }), - makeProjectHostSetup({ - id: 'r2', - projectId: 'git:git.example.com/acme/right', - repoId: 'r2' - }) - ] - }) - - const store = await createStore() - - // Equal repo overlap resolves by newest updatedAt; the loser's preference is never merged in. - expect(store.getProjects()).toEqual([ - expect.objectContaining({ - id: 'git:git.example.com/acme/shared', - sourceRepoIds: ['r1', 'r2'], - localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } - }) - ]) - }) - it('migrates legacy WSL agent settings into the global Windows runtime default', async () => { writeDataFile({ schemaVersion: 1, diff --git a/src/main/persistence-ssh-remote-pty-binding-replay.test.ts b/src/main/persistence-ssh-remote-pty-binding-replay.test.ts index f32f38368270..77054a2eafb0 100644 --- a/src/main/persistence-ssh-remote-pty-binding-replay.test.ts +++ b/src/main/persistence-ssh-remote-pty-binding-replay.test.ts @@ -393,61 +393,4 @@ describe('Store', () => { [TEST_LEAF_2]: 'runtime-pty-2' }) }) - - it('does not restore a binding with an explicit SSH termination tombstone', async () => { - const store = await createStore() - const hostId = 'ssh:ssh-1' - const session: WorkspaceSessionState = { - activeRepoId: 'r1', - activeWorktreeId: 'wt1', - activeTabId: 'tab1', - tabsByWorktree: { - wt1: [ - { - id: 'tab1', - worktreeId: 'wt1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1, - ptyId: 'ssh:ssh-1@@closed' - } - ] - }, - terminalLayoutsByTabId: { - tab1: { - root: { type: 'leaf', leafId: TEST_LEAF_1 }, - activeLeafId: TEST_LEAF_1, - expandedLeafId: null, - ptyIdsByLeafId: { [TEST_LEAF_1]: 'ssh:ssh-1@@closed' } - } - } - } - store.setWorkspaceSession(session, hostId) - store.upsertSshRemotePtyLease({ - targetId: 'ssh-1', - ptyId: 'closed', - worktreeId: 'wt1', - tabId: 'tab1', - leafId: TEST_LEAF_1, - state: 'terminated' - }) - store.setWorkspaceSession( - { - ...session, - tabsByWorktree: { - wt1: [{ ...session.tabsByWorktree.wt1[0]!, ptyId: null }] - }, - terminalLayoutsByTabId: { - tab1: { ...session.terminalLayoutsByTabId.tab1!, ptyIdsByLeafId: {} } - } - }, - hostId - ) - - const persisted = store.getWorkspaceSession(hostId) - expect(persisted.tabsByWorktree.wt1[0]!.ptyId).toBeNull() - expect(persisted.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({}) - }) }) diff --git a/src/main/persistence-ssh-targets-and-pane-keys.test.ts b/src/main/persistence-ssh-targets-and-pane-keys.test.ts index 4a1da77dc102..6cfb5332a8ed 100644 --- a/src/main/persistence-ssh-targets-and-pane-keys.test.ts +++ b/src/main/persistence-ssh-targets-and-pane-keys.test.ts @@ -267,6 +267,78 @@ describe('Store', () => { expect(updatedTarget).not.toHaveProperty('systemSshConnectionReuse') }) + it('persists known relay runtime choices and drops unknown ones', async () => { + const store = await createStore() + store.addSshTarget({ + id: 'ssh-runtime-pinned', + label: 'Pinned runtime', + host: 'pinned.example.com', + port: 22, + username: 'dev', + remoteRuntime: 'pinned-node' + }) + store.addSshTarget({ + id: 'ssh-runtime-host-node', + label: 'Host Node runtime', + host: 'host-node.example.com', + port: 22, + username: 'dev', + remoteRuntime: 'legacy' + }) + store.addSshTarget({ + id: 'ssh-runtime-unknown', + label: 'Newer build value', + host: 'newer.example.com', + port: 22, + username: 'dev', + // A value from a newer build, which this one must not act on. + ...JSON.parse('{"remoteRuntime":"auto"}') + }) + + store.flush() + const persisted = readDataFile() as { sshTargets?: Record[] } + const byId = (id: string) => persisted.sshTargets?.find((t) => t.id === id) + expect(byId('ssh-runtime-pinned')?.remoteRuntime).toBe('pinned-node') + // An explicit Host Node choice must outlive a later default flip. + expect(byId('ssh-runtime-host-node')?.remoteRuntime).toBe('legacy') + expect(byId('ssh-runtime-unknown')).not.toHaveProperty('remoteRuntime') + }) + + it('keeps a well-formed runtime ladder decision and drops a malformed one', async () => { + const store = await createStore() + const resolution = { + rung: 'C', + pinnedRefusal: 'illegal_instruction', + glibc: '2.28', + runtimeSha256: 'a'.repeat(64), + orcaMajor: 1 + } + store.addSshTarget({ + id: 'ssh-ladder-ok', + label: 'Ladder ok', + host: 'ok.example.com', + port: 22, + username: 'dev', + ...JSON.parse(JSON.stringify({ remoteRuntimeResolution: resolution })) + }) + store.addSshTarget({ + id: 'ssh-ladder-bad', + label: 'Ladder bad', + host: 'bad.example.com', + port: 22, + username: 'dev', + ...JSON.parse( + JSON.stringify({ remoteRuntimeResolution: { ...resolution, runtimeSha256: '../x' } }) + ) + }) + + store.flush() + const persisted = readDataFile() as { sshTargets?: Record[] } + const byId = (id: string) => persisted.sshTargets?.find((t) => t.id === id) + expect(byId('ssh-ladder-ok')?.remoteRuntimeResolution).toEqual(resolution) + expect(byId('ssh-ladder-bad')).not.toHaveProperty('remoteRuntimeResolution') + }) + it('drops retired per-target SSH terminal source-credit selections', async () => { const store = await createStore() store.addSshTarget({ diff --git a/src/main/persistence-worktree-card-properties.test.ts b/src/main/persistence-worktree-card-properties.test.ts index e370168af4f4..555ae14caf7a 100644 --- a/src/main/persistence-worktree-card-properties.test.ts +++ b/src/main/persistence-worktree-card-properties.test.ts @@ -81,7 +81,8 @@ describe('Store', () => { 'pr', 'comment', 'ports', - 'inline-agents' + 'inline-agents', + 'host' ]) expect(store.getUI()._inlineAgentsDefaultedForAllUsers).toBe(true) expect(store.getUI()._expandedWorktreeCardPropertiesDefaulted).toBe(true) @@ -118,7 +119,8 @@ describe('Store', () => { 'pr', 'comment', 'ports', - 'inline-agents' + 'inline-agents', + 'host' ]) expect(store.getUI().worktreeCardProperties).not.toContain('branch') expect(store.getUI()._inlineAgentsDefaultedForAllUsers).toBe(true) @@ -148,7 +150,8 @@ describe('Store', () => { 'cli', 'comment', 'ports', - 'inline-agents' + 'inline-agents', + 'host' ]) expect(store.getUI().worktreeCardProperties).not.toContain('branch') expect(store.getUI()._worktreeCardModeDefaulted).toBe(true) @@ -178,7 +181,8 @@ describe('Store', () => { 'jira-issue', 'pr', 'ports', - 'inline-agents' + 'inline-agents', + 'host' ]) expect(store.getUI().worktreeCardProperties).not.toContain('branch') }) @@ -200,7 +204,7 @@ describe('Store', () => { }) const store = await createStore() - expect(store.getUI().worktreeCardProperties).toEqual(['status', 'unread', 'pr']) + expect(store.getUI().worktreeCardProperties).toEqual(['status', 'unread', 'pr', 'host']) expect(store.getUI().worktreeCardProperties).not.toContain('branch') expect(store.getUI().worktreeCardProperties).not.toContain('ports') expect(store.getUI().worktreeCardProperties).not.toContain('inline-agents') @@ -268,7 +272,8 @@ describe('Store', () => { 'linear-issue', 'pr', 'comment', - 'ports' + 'ports', + 'host' ]) expect(store.getUI().worktreeCardProperties).not.toContain('branch') expect(store.getUI().worktreeCardProperties).not.toContain('inline-agents') @@ -306,7 +311,8 @@ describe('Store', () => { 'jira-issue', 'pr', 'ports', - 'inline-agents' + 'inline-agents', + 'host' ]) expect( store.getUI().worktreeCardProperties?.filter((property) => property === 'jira-issue') @@ -336,7 +342,8 @@ describe('Store', () => { 'unread', 'issue', 'linear-issue', - 'pr' + 'pr', + 'host' ]) expect(store.getUI().worktreeCardProperties).not.toContain('jira-issue') }) @@ -413,6 +420,51 @@ describe('Store', () => { expect(store.getUI().worktreeCardProperties).not.toContain('inline-agents') }) + it('backfills host once for profiles stamped before it became a card property', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { compactWorktreeCards: false }, + ui: { + worktreeCardProperties: ['status', 'unread', 'pr', 'ports', 'inline-agents'], + _inlineAgentsDefaultedForAllUsers: true, + _expandedWorktreeCardPropertiesDefaulted: true, + _jiraIssueWorktreeCardPropertyDefaulted: true + }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + + expect( + store.getUI().worktreeCardProperties?.filter((property) => property === 'host') + ).toHaveLength(1) + expect(store.getUI()._hostWorktreeCardPropertyDefaulted).toBe(true) + }) + + it('preserves a deliberate host removal after the backfill has run', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { compactWorktreeCards: false }, + ui: { + worktreeCardProperties: ['status', 'unread', 'pr'], + _inlineAgentsDefaultedForAllUsers: true, + _expandedWorktreeCardPropertiesDefaulted: true, + _jiraIssueWorktreeCardPropertyDefaulted: true, + _hostWorktreeCardPropertyDefaulted: true + }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + + expect(store.getUI().worktreeCardProperties).toEqual(['status', 'unread', 'pr']) + expect(store.getUI().worktreeCardProperties).not.toContain('host') + }) + it.each([ ['raw', ['status', 'automation']], ['normalized', ['status', 'unread', 'automation']] diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts b/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts index a23bd421dd59..58414b934a56 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts @@ -1,9 +1,15 @@ +import { z } from 'zod' import type { SshPtyConsumerRecovery, SshRemotePtyLease, + SshRemoteRuntimeResolution, SshTarget } from '../../../shared/ssh-types' -import { LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../../../shared/ssh-types' +import { + LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, + SSH_REMOTE_RUNTIME_RUNGS, + SSH_REMOTE_RUNTIMES +} from '../../../shared/ssh-types' import { normalizeSshPendingPtyKill } from '../../../shared/ssh-pending-pty-kill' export type LegacySshTarget = SshTarget & { @@ -19,11 +25,17 @@ export function normalizeSshTarget(t: SshTarget): SshTarget { const currentGracePeriodSeconds = target.relayGracePeriodSeconds const legacyGracePeriodSeconds = target.remoteWorkspaceSyncGracePeriodSeconds const systemSshConnectionReuse = target.systemSshConnectionReuse + const remoteRuntime = target.remoteRuntime + const remoteRuntimeResolution = normalizeSshRemoteRuntimeResolution( + target.remoteRuntimeResolution + ) // Why: remote sync now follows the SSH relay lifecycle, so retired per-target sync/grace fields are dropped at disk load. delete target.remoteWorkspaceSyncEnabled delete target.remoteWorkspaceSyncGracePeriodSeconds delete target.relayGracePeriodSeconds delete target.systemSshConnectionReuse + delete target.remoteRuntime + delete target.remoteRuntimeResolution delete target.experimentalPtySourceCreditV1 // Why: prefer the synced grace over stale relayGracePeriodSeconds so a user's "unlimited" (0) survives migration. const relayGracePeriodSeconds = @@ -44,9 +56,37 @@ export function normalizeSshTarget(t: SshTarget): SshTarget { if (systemSshConnectionReuse === false) { normalized.systemSshConnectionReuse = false } + // Known values survive, so an explicit Host Node choice outlives a later default flip; + // an unknown value from a newer build must not change the runtime. + const knownRuntime = SSH_REMOTE_RUNTIMES.find((runtime) => runtime === remoteRuntime) + if (knownRuntime) { + normalized.remoteRuntime = knownRuntime + } + if (remoteRuntimeResolution) { + normalized.remoteRuntimeResolution = remoteRuntimeResolution + } return normalized } +// Why strict: a malformed cache entry is dropped, which only costs one rung A attempt. +const SshRemoteRuntimeResolutionSchema = z + .object({ + rung: z.enum(SSH_REMOTE_RUNTIME_RUNGS), + pinnedRefusal: z.string().max(64).optional(), + glibc: z + .string() + .regex(/^\d+\.\d+$/) + .nullable(), + runtimeSha256: z.string().regex(/^[0-9a-f]{64}$/), + orcaMajor: z.number().int().nonnegative() + }) + .strict() + +function normalizeSshRemoteRuntimeResolution(value: unknown): SshRemoteRuntimeResolution | null { + const parsed = SshRemoteRuntimeResolutionSchema.safeParse(value) + return parsed.success ? parsed.data : null +} + // Why: strict whitelist — a record missing or mistyping a required field is dropped rather than partially trusted. export function normalizeSshRemotePtyLease(value: unknown): SshRemotePtyLease | null { if (!value || typeof value !== 'object') { diff --git a/src/main/persistence/loading-store/normalize-loaded-ui-state.ts b/src/main/persistence/loading-store/normalize-loaded-ui-state.ts index 4b208a6fa72c..8a4f838f1930 100644 --- a/src/main/persistence/loading-store/normalize-loaded-ui-state.ts +++ b/src/main/persistence/loading-store/normalize-loaded-ui-state.ts @@ -78,6 +78,7 @@ export function normalizeLoadedUiState( const inlineAgentsMigrated = parsed.ui?._inlineAgentsDefaultedForAllUsers === true const expandedCardPropsMigrated = parsed.ui?._expandedWorktreeCardPropertiesDefaulted === true const jiraIssueCardPropDefaulted = parsed.ui?._jiraIssueWorktreeCardPropertyDefaulted === true + const hostCardPropDefaulted = parsed.ui?._hostWorktreeCardPropertyDefaulted === true const hadExperimentOn = readDeprecatedExperimentFlag(parsed) const deliberateUncheck = hadExperimentOn && Array.isArray(rawCardProps) && !rawCardProps.includes('inline-agents') @@ -119,7 +120,12 @@ export function normalizeLoadedUiState( jiraIssueCardPropDefaulted || expandedCandidate.includes('jira-issue') ? expandedCandidate : [...expandedCandidate, 'jira-issue' as const] - const normalized = normalizeWorktreeCardProperties(jiraCandidate) + // Why: the host pill was unconditional before it became a property, so existing profiles get it back once rather than silently losing it. + const hostCandidate = + hostCardPropDefaulted || jiraCandidate.includes('host') + ? jiraCandidate + : [...jiraCandidate, 'host' as const] + const normalized = normalizeWorktreeCardProperties(hostCandidate) const changed = normalized.length !== rawCardProps.length || normalized.some((property, index) => property !== rawCardProps[index]) @@ -129,7 +135,8 @@ export function normalizeLoadedUiState( migratedCardProps !== undefined || !inlineAgentsMigrated || !expandedCardPropsMigrated || - !jiraIssueCardPropDefaulted + !jiraIssueCardPropDefaulted || + !hostCardPropDefaulted ) { markNeedsSave() } @@ -213,6 +220,7 @@ export function normalizeLoadedUiState( _inlineAgentsDefaultedForExperiment: true, _inlineAgentsDefaultedForAllUsers: true, _expandedWorktreeCardPropertiesDefaulted: true, - _jiraIssueWorktreeCardPropertyDefaulted: true + _jiraIssueWorktreeCardPropertyDefaulted: true, + _hostWorktreeCardPropertyDefaulted: true } } diff --git a/src/main/persistence/profile-state/profile-state-backup-worker.test.ts b/src/main/persistence/profile-state/profile-state-backup-worker.test.ts index 310c14714201..26457dd44348 100644 --- a/src/main/persistence/profile-state/profile-state-backup-worker.test.ts +++ b/src/main/persistence/profile-state/profile-state-backup-worker.test.ts @@ -15,6 +15,7 @@ import { readProfileStateSnapshot } from './profile-state-documents' import { + runProfileStateBackup, runProfileStateBackupWorker, resolveProfileStateBackupWorkerPath } from './profile-state-backup-worker' @@ -229,6 +230,48 @@ describe('profile state backup worker', () => { rmSync(directory, { recursive: true }) }) + describe.skipIf(!!process.versions.electron)('under plain Node', () => { + it('uses the bundled worker whenever its entry exists', async () => { + const { directory, job } = fixture() + const marker = script( + directory, + ` + const { parentPort, workerData } = require('node:worker_threads') + require('node:fs').writeFileSync(workerData.targetPath, 'worker thread') + parentPort.postMessage({ ok: true }) + ` + ) + await runProfileStateBackup(job, undefined, marker) + expect(readFileSync(job.targetPath, 'utf8')).toBe('worker thread') + }) + + it('runs the built entry off-thread and publishes a readable backup', async () => { + const { job } = fixture() + await runProfileStateBackup(job, undefined, workerPath) + const snapshot = openProfileStateDatabaseReadOnly(job.targetPath, job.profileId) + try { + expect(JSON.parse(readProfileStateSnapshot(snapshot.db).json)).toEqual({ + settings: { theme: 'dark' } + }) + } finally { + snapshot.db.close() + } + }) + + it('falls back to inline validation only when no entry was bundled', async () => { + const { directory, job } = fixture() + await runProfileStateBackup(job, undefined, join(directory, 'missing.js')) + const snapshot = openProfileStateDatabaseReadOnly(job.targetPath, job.profileId) + try { + expect(JSON.parse(readProfileStateSnapshot(snapshot.db).json)).toEqual({ + settings: { theme: 'dark' } + }) + } finally { + snapshot.db.close() + } + }) + }) + it('finds entries beside the launcher and above Rollup shared chunks', () => { expect(resolveProfileStateBackupWorkerPath(workerDirectory)).toBe(workerPath) expect(resolveProfileStateBackupWorkerPath(join(workerDirectory, 'chunks'))).toBe(workerPath) diff --git a/src/main/persistence/profile-state/profile-state-backup-worker.ts b/src/main/persistence/profile-state/profile-state-backup-worker.ts index b671df941122..6955af6ab7c0 100644 --- a/src/main/persistence/profile-state/profile-state-backup-worker.ts +++ b/src/main/persistence/profile-state/profile-state-backup-worker.ts @@ -20,13 +20,15 @@ export function resolveProfileStateBackupWorkerPath(moduleDir = __dirname): stri return [entry, join(dirname(entry), '..', WORKER_FILENAME)].find(existsSync) ?? entry } -/** Bun snapshot copying and desktop validation run off the owning runtime thread. */ +/** Snapshot copying and full validation run off the owning runtime thread. */ export function runProfileStateBackup( job: ProfileStateBackupJob, - signal?: AbortSignal + signal?: AbortSignal, + workerPath = resolveProfileStateBackupWorkerPath() ): Promise { - return process.versions.electron || process.versions.bun - ? runProfileStateBackupWorker(job, { signal }) + // Electron always ships the entry, so a missing one there must fail, not block the thread. + return process.versions.electron || existsSync(workerPath) + ? runProfileStateBackupWorker(job, { workerPath, signal }) : writeProfileStateBackup(job) } diff --git a/src/main/persistence/profile-state/profile-state-cross-runtime.integration.test.ts b/src/main/persistence/profile-state/profile-state-cross-runtime.integration.test.ts new file mode 100644 index 000000000000..8a4be067eb4b --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-cross-runtime.integration.test.ts @@ -0,0 +1,142 @@ +/** + * Design D4 Phase 1 gate: a profile database Bun 1.4.2 wrote, WAL included after an unclean + * exit, opens and backs up under the pinned Node, and the reverse — the rollback direction. + * Today's source reaches Bun's own SQLite through its node:sqlite; the Bun adapter is gone. + */ +import { build } from 'esbuild' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { runProcess } from '../../../shared/child-process/run-process' +import { + buildProfileStateCutoverFixture, + canonicalProfileStateJson +} from '../profile-state-cutover-fixture' +import { + locateBunForTests, + locatePinnedNodeForTests, + skipForMissingInputs +} from '../../orcad/orcad-node-slot-fixture' + +const bun = locateBunForTests() +const node = locatePinnedNodeForTests() +const directory = mkdtempSync(join(tmpdir(), 'orca-profile-cross-runtime-')) +const entry = join(directory, 'cross-runtime.cjs') +const PROFILE_ID = 'cross-runtime' + +beforeAll(async () => { + await build({ + stdin: { + contents: ` + import { openProfileStateDatabase, openProfileStateDatabaseReadOnly } from './src/main/persistence/profile-state/profile-state-database' + import { exportProfileStateJson, importProfileStateJson, readProfileStateRevision } from './src/main/persistence/profile-state/profile-state-documents' + import { writeProfileStateBackup } from './src/main/persistence/profile-state/profile-state-backup-job' + const [op, dbPath, profileId, payloadPath, backupPath] = process.argv.slice(2) + const runtime = process.versions.bun ? 'bun' : 'node' + if (op === 'write-unclean') { + const opened = openProfileStateDatabase(dbPath, profileId) + // Keep every committed frame in the WAL so the reader must replay it. + opened.db.pragma('wal_autocheckpoint = 0') + importProfileStateJson(opened.db, require('fs').readFileSync(payloadPath, 'utf8'), { now: () => 100 }) + console.log(JSON.stringify({ runtime, revision: readProfileStateRevision(opened.db) })) + // Why SIGKILL: Bun closes and checkpoints open databases even on process.exit(). + process.kill(process.pid, 'SIGKILL') + } + ;(async () => { + const opened = openProfileStateDatabase(dbPath, profileId) + const exported = exportProfileStateJson(opened.db) + const revision = readProfileStateRevision(opened.db) + opened.db.close() + await writeProfileStateBackup({ databasePath: dbPath, profileId, targetPath: backupPath }) + const backup = openProfileStateDatabaseReadOnly(backupPath, profileId) + const backedUp = exportProfileStateJson(backup.db) + backup.db.close() + console.log(JSON.stringify({ runtime, revision, exported, backedUp })) + })().catch(error => { console.error(error); process.exit(1) }) + `, + resolveDir: process.cwd(), + loader: 'ts' + }, + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node18', + external: ['electron'], + outfile: entry, + logLevel: 'silent' + }) +}) + +afterAll(() => { + rmSync(directory, { recursive: true, force: true }) +}) + +async function run( + program: string, + args: string[], + exit: 'clean' | 'killed' = 'clean' +): Promise> { + const result = await runProcess({ + program, + args: [entry, ...args], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 60_000 + }) + if (exit === 'killed' && process.platform !== 'win32') { + expect(result.signal, result.stderr).toBe('SIGKILL') + } else if (exit === 'clean') { + expect(result.code, result.stderr).toBe(0) + } + const parsed: unknown = JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '') + if (!parsed || typeof parsed !== 'object') { + throw new Error(`Unexpected fixture output: ${result.stdout}`) + } + return Object.fromEntries(Object.entries(parsed)) +} + +const skip = skipForMissingInputs('cross-runtime', [ + ...(bun ? [] : ['Bun 1.4.2 (BUN_EXECUTABLE or bun on PATH)']), + ...(node ? [] : ['the pinned Node (ORCA_PINNED_NODE or out/runtimes)']) +]) + +describe.skipIf(skip)('profile database across Bun 1.4.2 and the pinned Node', () => { + it.each([ + ['Bun', 'Node'], + ['Node', 'Bun'] + ] as const)( + 'opens and backs up under %s -> %s after an unclean exit', + async (writer, reader) => { + const runtimes = { Bun: bun!, Node: node! } + const caseDir = mkdtempSync(join(directory, `${writer}-to-${reader}-`)) + const dbPath = join(caseDir, 'profile-state.db') + const payloadPath = join(caseDir, 'payload.json') + const fixture = buildProfileStateCutoverFixture() + writeFileSync(payloadPath, JSON.stringify(fixture)) + + const written = await run( + runtimes[writer], + ['write-unclean', dbPath, PROFILE_ID, payloadPath], + 'killed' + ) + expect(written.runtime).toBe(writer.toLowerCase()) + expect(existsSync(`${dbPath}-wal`)).toBe(true) + expect(readFileSync(`${dbPath}-wal`).byteLength).toBeGreaterThan(0) + + const backupPath = join(caseDir, 'backup.db') + const read = await run(runtimes[reader], [ + 'read-and-backup', + dbPath, + PROFILE_ID, + '', + backupPath + ]) + expect(read.runtime).toBe(reader.toLowerCase()) + expect(read.revision).toBe(written.revision) + const expected = canonicalProfileStateJson(fixture) + expect(canonicalProfileStateJson(JSON.parse(String(read.exported)))).toBe(expected) + expect(canonicalProfileStateJson(JSON.parse(String(read.backedUp)))).toBe(expected) + }, + 60_000 + ) +}) diff --git a/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts b/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts index e0147cb937f4..da0ebcf7ac57 100644 --- a/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts +++ b/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts @@ -96,8 +96,7 @@ describe('asynchronous profile-state database snapshots', () => { expect(existsSync(targetPath)).toBe(false) }) - // Node's incremental backup callback is not part of Bun's worker snapshot contract. - it.skipIf(!!process.versions.bun).each(['same connection', 'another connection'] as const)( + it.each(['same connection', 'another connection'] as const)( 'keeps a consistent complete revision while writes occur from %s', async (connection) => { const { directory, databasePath, db, targetPath } = fixture() @@ -246,24 +245,21 @@ describe('asynchronous profile-state database snapshots', () => { expectNoTemporaryFiles(directory) }) - it.skipIf(!!process.versions.bun)( - 'fails clearly when native backup is unsupported without replacing the destination', - async () => { - const { directory, db, targetPath } = fixture() - writeFileSync(targetPath, 'previous recovery artifact') - const getBuiltinModule = process.getBuiltinModule.bind(process) - vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => - id === 'node:sqlite' ? {} : getBuiltinModule(id) - ) + it('fails clearly when native backup is unsupported without replacing the destination', async () => { + const { directory, db, targetPath } = fixture() + writeFileSync(targetPath, 'previous recovery artifact') + const getBuiltinModule = process.getBuiltinModule.bind(process) + vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => + id === 'node:sqlite' ? {} : getBuiltinModule(id) + ) - await expect(writeProfileStateDatabaseSnapshotAsync(db, targetPath)).rejects.toThrow( - 'Asynchronous SQLite backup is unavailable' - ) + await expect(writeProfileStateDatabaseSnapshotAsync(db, targetPath)).rejects.toThrow( + 'Asynchronous SQLite backup is unavailable' + ) - expect(readFileSync(targetPath, 'utf8')).toBe('previous recovery artifact') - expectNoTemporaryFiles(directory) - } - ) + expect(readFileSync(targetPath, 'utf8')).toBe('previous recovery artifact') + expectNoTemporaryFiles(directory) + }) it.each(['', 'invalid\0path'])('rejects the invalid target %j', async (path) => { const { db } = fixture() diff --git a/src/main/persistence/profile-state/profile-state-startup-authority.test.ts b/src/main/persistence/profile-state/profile-state-startup-authority.test.ts index 539556d788f5..04f955c066e3 100644 --- a/src/main/persistence/profile-state/profile-state-startup-authority.test.ts +++ b/src/main/persistence/profile-state/profile-state-startup-authority.test.ts @@ -134,7 +134,7 @@ describe('profile-state startup authority boundary', () => { it('rejects direct orcad startup on an incapable runtime', async () => { const original = process.getBuiltinModule vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => { - if (id === 'node:sqlite' || id === 'bun:sqlite') { + if (id === 'node:sqlite') { return undefined } return original(id) @@ -247,12 +247,9 @@ describe('profile-state startup authority boundary', () => { it('refuses a runtime with SQLite but no native backup', async () => { const original = process.getBuiltinModule - vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => { - if (id === 'bun:sqlite') { - return undefined - } - return id === 'node:sqlite' ? { DatabaseSync: class {} } : original(id) - }) + vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => + id === 'node:sqlite' ? { DatabaseSync: class {} } : original(id) + ) await expect( createProfileStateStoreForStartup({ dataFile: join(tmpdir(), 'missing-backup-orca-data.json'), @@ -273,7 +270,7 @@ describe('profile-state startup authority boundary', () => { writeFileSync(dataFile, source) const original = process.getBuiltinModule vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => - id === 'node:sqlite' || id === 'bun:sqlite' ? undefined : original(id) + id === 'node:sqlite' ? undefined : original(id) ) await expect( diff --git a/src/main/persistence/profile-state/profile-state-startup-authority.ts b/src/main/persistence/profile-state/profile-state-startup-authority.ts index 55c9a0314555..10307ac359f4 100644 --- a/src/main/persistence/profile-state/profile-state-startup-authority.ts +++ b/src/main/persistence/profile-state/profile-state-startup-authority.ts @@ -23,7 +23,7 @@ export class ProfileStateStartupAuthorityError extends Error { constructor() { super( - 'orcad requires SQLite database and backup support. Launch through its bundled Bun runtime.' + 'orcad requires SQLite database and backup support. Launch it through its pinned Node runtime.' ) this.name = 'ProfileStateStartupAuthorityError' } diff --git a/src/main/persistence/profile-state/profile-state-store-factory.test.ts b/src/main/persistence/profile-state/profile-state-store-factory.test.ts index 45ceade86981..45b6d49f791b 100644 --- a/src/main/persistence/profile-state/profile-state-store-factory.test.ts +++ b/src/main/persistence/profile-state/profile-state-store-factory.test.ts @@ -141,7 +141,7 @@ describe('profile state Store authority factory', () => { it('uses a capability probe that remains false on a Node 18-style host', () => { const original = process.getBuiltinModule vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => { - if (id === 'node:sqlite' || id === 'bun:sqlite') { + if (id === 'node:sqlite') { return undefined } return original(id) @@ -358,7 +358,7 @@ describe('profile state Store authority factory', () => { } const original = process.getBuiltinModule vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => - id === 'node:sqlite' || id === 'bun:sqlite' ? undefined : original(id) + id === 'node:sqlite' ? undefined : original(id) ) expect(() => createProfileStateStore(options)).toThrow('Writable profiles require SQLite') expect(existsSync(options.databaseFile)).toBe(false) diff --git a/src/main/persistence/profile-state/profile-state-store-factory.ts b/src/main/persistence/profile-state/profile-state-store-factory.ts index a40b19a17267..90d21fc2e42a 100644 --- a/src/main/persistence/profile-state/profile-state-store-factory.ts +++ b/src/main/persistence/profile-state/profile-state-store-factory.ts @@ -65,7 +65,7 @@ export function prepareProfileStateStore( const authority = bootstrap.authority if (authority === undefined) { throw new ProfileStateStoreFactoryError( - 'Writable profiles require SQLite database and backup support. Use Orca or its bundled Bun runtime.' + 'Writable profiles require SQLite database and backup support. Use Orca or its pinned Node runtime.' ) } diff --git a/src/main/persistence/profile-state/profile-state-worker-export-failures.test.ts b/src/main/persistence/profile-state/profile-state-worker-export-failures.test.ts index 56e21da43e56..7ada3f595520 100644 --- a/src/main/persistence/profile-state/profile-state-worker-export-failures.test.ts +++ b/src/main/persistence/profile-state/profile-state-worker-export-failures.test.ts @@ -66,9 +66,7 @@ async function fixture(fault: 'rename' | 'commit' | 'exit' | 'read-rollback' | ' const faultSource = fault === 'commit' || fault === 'read-rollback' ? ` - const DatabaseSync = process.versions.bun - ? require('bun:sqlite').Database - : require('node:sqlite').DatabaseSync + const DatabaseSync = require('node:sqlite').DatabaseSync const { existsSync } = require('node:fs') let writing = false const exec = DatabaseSync.prototype.exec diff --git a/src/main/providers/agent-foreground-command-line.test.ts b/src/main/providers/agent-foreground-command-line.test.ts new file mode 100644 index 000000000000..b7479d15461b --- /dev/null +++ b/src/main/providers/agent-foreground-command-line.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock } = vi.hoisted(() => ({ + execFileMock: vi.fn() +})) + +vi.mock('child_process', () => ({ + execFile: execFileMock +})) + +import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot-reader' +import { resolveAgentForegroundCommandLine } from './agent-foreground-process' + +// Why: the POSIX reader wraps execFile with promisify, so the mock honors the Node callback contract. +function mockPs(rows: string[]): void { + execFileMock.mockImplementation( + ( + _cmd: string, + _args: string[], + _opts: unknown, + callback: (err: unknown, result: { stdout: string; stderr: string }) => void + ) => callback(null, { stdout: rows.join('\n'), stderr: '' }) + ) +} + +describe('resolveAgentForegroundCommandLine', () => { + let platform: PropertyDescriptor | undefined + + beforeEach(() => { + execFileMock.mockReset() + resetProcessTableSnapshotForTests() + platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + }) + + afterEach(() => { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + }) + + it("returns the foreground agent's full command line", async () => { + mockPs([ + '100 99 100 101 Ss /bin/zsh -l', + '101 100 101 101 S+ /opt/homebrew/bin/opencode run fix the bug', + '102 100 102 101 S node background.js' + ]) + + await expect(resolveAgentForegroundCommandLine(100)).resolves.toBe( + '/opt/homebrew/bin/opencode run fix the bug' + ) + }) + + it('returns null when no agent holds the foreground', async () => { + mockPs([ + '100 99 100 102 Ss /bin/zsh -l', + '101 100 101 102 T opencode run paused', + '102 100 102 102 S+ vim notes.txt' + ]) + + await expect(resolveAgentForegroundCommandLine(100)).resolves.toBeNull() + }) + + it('returns null when the capture fails', async () => { + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, callback: (err: unknown) => void) => + callback(new Error('ps failed')) + ) + + await expect(resolveAgentForegroundCommandLine(100)).resolves.toBeNull() + }) +}) diff --git a/src/main/providers/agent-foreground-process-git-bash.win32.test.ts b/src/main/providers/agent-foreground-process-git-bash.win32.test.ts index 4133375180fa..89c074d41665 100644 --- a/src/main/providers/agent-foreground-process-git-bash.win32.test.ts +++ b/src/main/providers/agent-foreground-process-git-bash.win32.test.ts @@ -7,8 +7,6 @@ import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' import { confirmPtyShellForeground } from '../daemon/pty-subprocess/pty-shell-foreground-confirmation' import { createPtyShellLaunchPlan } from '../daemon/pty-subprocess/shell-launch-plan' import { spawnNativeDaemonPty } from '../daemon/pty-subprocess/native-pty-spawn' -import { canUseBunPty, spawnBunPty } from '../daemon/pty-subprocess/bun-pty-process' -import { createWindowsBunPtyLaunch } from '../daemon/pty-subprocess/windows-bun-pty-launch' import { createDaemonPtyEnvironment } from '../daemon/pty-subprocess/spawn-environment' import type { PtyShellLaunchPlan } from '../daemon/pty-subprocess/shell-launch-plan' import type { PtySubprocessOptions } from '../daemon/pty-subprocess' @@ -22,20 +20,7 @@ async function spawnPlannedPane( env: Record, opts: PtySubprocessOptions ): ReturnType { - return spawnNativeDaemonPty( - { ...plan, env, cols: opts.cols, rows: opts.rows }, - { - canUseBunPty, - spawnBunPty: (args) => - spawnBunPty(args, { - // Source tests use the TS worker; packaged hosts resolve their adjacent JS worker. - createWindowsLaunch: (launch) => - createWindowsBunPtyLaunch(launch, { - workerPath: join(__dirname, '../daemon/pty-subprocess/windows-bun-pty-gate-entry.ts') - }) - }) - } - ) + return spawnNativeDaemonPty({ ...plan, env, cols: opts.cols, rows: opts.rows }) } /** Drives a live pane through prompt -> foreground command -> interrupt -> background job. */ diff --git a/src/main/providers/agent-foreground-process.ts b/src/main/providers/agent-foreground-process.ts index 84b147ca3e95..da6929ff410c 100644 --- a/src/main/providers/agent-foreground-process.ts +++ b/src/main/providers/agent-foreground-process.ts @@ -13,7 +13,10 @@ import { type AgentForegroundResolutionOptions } from './windows-agent-foreground-process' import { isShellProcess } from '../../shared/shell-process-detection' -import { selectForegroundProcessCandidate } from '../../shared/foreground-process-selection' +import { + selectForegroundProcessCandidate, + type SelectedForegroundProcess +} from '../../shared/foreground-process-selection' import { isWindowsShellAloneInJob } from './windows-shell-alone-in-job' import { readWindowsProcessIdentityTableFresh, @@ -180,6 +183,35 @@ export function resolveAgentForegroundProcessFromPs( rows: readonly ProcessTableRow[], shellPid: number ): string | null { + const found = selectAgentForegroundFromPs(rows, shellPid) + // Why: return the outer wrapper (omp) rather than the deeper wrapped child + // (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess. + return found + ? resolveOuterWrapperForegroundProcess( + found.selected.recognized, + found.selected.candidate, + found.candidates + ) + : null +} + +/** The full command line of the pane's foreground agent process, for argv-level policy. */ +export async function resolveAgentForegroundCommandLine(shellPid: number): Promise { + try { + const rows = await getFreshShellForegroundSnapshot() + return selectAgentForegroundFromPs(rows, shellPid)?.selected.candidate.command ?? null + } catch { + return null + } +} + +function selectAgentForegroundFromPs( + rows: readonly ProcessTableRow[], + shellPid: number +): { + selected: SelectedForegroundProcess + candidates: (ProcessTableRow & { depth: number })[] +} | null { // Memoized per snapshot identity, so the caller's own index build is reused. const index = getProcessTableIndex(rows) const shellRow = index.byPid.get(shellPid) @@ -198,10 +230,5 @@ export function resolveAgentForegroundProcessFromPs( // helper is filtered from selection but must remain traversable. const ancestryCandidates = shellRow ? [{ ...shellRow, depth: 0 }, ...candidates] : candidates const selected = selectForegroundProcessCandidate(foregroundCandidates, ancestryCandidates) - if (selected) { - // Why: return the outer wrapper (omp) rather than the deeper wrapped child - // (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess. - return resolveOuterWrapperForegroundProcess(selected.recognized, selected.candidate, candidates) - } - return null + return selected ? { selected, candidates } : null } diff --git a/src/main/providers/local-pty-bun-artifact.integration.test.ts b/src/main/providers/local-pty-bun-artifact.integration.test.ts deleted file mode 100644 index 487127887a34..000000000000 --- a/src/main/providers/local-pty-bun-artifact.integration.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { build } from 'esbuild' -import { existsSync } from 'node:fs' -import { copyFile, mkdtemp } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' -import { describe, expect, it } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' -import { orcadBunRuntimeFilename } from '../../shared/orcad-artifacts' -import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' -import { removeTreeSync } from '../../shared/windows-transient-lock-removal' - -const runtime = - process.env.BUN_EXECUTABLE ?? resolve('out/orcad', orcadBunRuntimeFilename(process.platform)) - -describe.skipIf(!existsSync(runtime))('isolated Bun in-process PTY artifact', () => { - it('spawns, reattaches, delivers data and retires a shell without node-pty installed', async () => { - const directory = await mkdtemp(join(tmpdir(), 'orca-bun-local-pty-')) - try { - const entry = join(directory, 'local-pty.cjs') - const external = ['node-pty', 'electron', 'bun:ffi', '@parcel/watcher', '*.node'] - await build({ - stdin: { - contents: ` - import { LocalPtyProvider } from './src/main/providers/local-pty-provider' - import { setAppEnvironment } from './src/shared/app-environment' - import { getCmdExePath } from './src/shared/windows-batch-spawn' - try { require.resolve('node-pty'); throw new Error('node-pty unexpectedly available') } - catch (error) { if (error.code !== 'MODULE_NOT_FOUND') throw error } - setAppEnvironment({ - getPath: () => process.cwd(), getAppPath: () => process.cwd(), - getVersion: () => 'test', isPackaged: () => true, - onWillQuit() {}, exit: code => process.exit(code), getAppMetrics: () => [] - }) - const provider = new LocalPtyProvider() - let output = '', resolveExit - const exit = new Promise(resolve => { resolveExit = resolve }) - provider.onData(event => { output += event.data }) - provider.onExit(event => resolveExit(event.code)) - const deadline = setTimeout(() => { provider.killAll(); process.exit(98) }, 10_000) - ;(async () => { - const first = await provider.spawn({ - sessionId: 'isolated-bun-fallback', cols: 80, rows: 24, cwd: process.cwd(), - shellOverride: process.platform === 'win32' ? getCmdExePath() : '/bin/sh' - }) - const again = await provider.spawn({sessionId:first.id, cols:100, rows:30}) - provider.write(first.id, process.platform === 'win32' - ? 'echo ORCA_BUN_FALLBACK_READY & exit 17\\r' - : 'printf ORCA_BUN_FALLBACK_READY; exit 17\\r') - const code = await exit - clearTimeout(deadline) - console.log(JSON.stringify({ - version:process.versions.bun, code, output:output.includes('ORCA_BUN_FALLBACK_READY'), - reattached:again.isReattach === true && again.pid === first.pid, - retired:provider.getPtyProcess(first.id) === undefined - })) - })().catch(error => { clearTimeout(deadline); provider.killAll(); console.error(error);process.exitCode=1 }) - `, - resolveDir: process.cwd(), - loader: 'ts' - }, - bundle: true, - platform: 'node', - format: 'cjs', - target: 'node18', - external, - outfile: entry, - logLevel: 'silent' - }) - if (process.platform === 'win32') { - await build({ - entryPoints: ['src/main/daemon/pty-subprocess/windows-bun-pty-gate-entry.ts'], - bundle: true, - platform: 'node', - format: 'cjs', - external, - outfile: join(directory, 'windows-bun-pty-gate-entry.js'), - logLevel: 'silent' - }) - } - const isolatedRuntime = join(directory, orcadBunRuntimeFilename(process.platform)) - await copyFile(runtime, isolatedRuntime) - const result = await runProcess({ - program: isolatedRuntime, - args: ['--no-install', entry], - cwd: directory, - env: { - ...process.env, - ORCA_BACKGROUND_LAUNCH: '1', - ORCA_DISABLE_MACOS_LOGIN_SHELL: '1', - ORCA_USER_DATA_PATH: directory - }, - timeoutMs: 15_000, - terminationBarrier: true - }) - expect(result.code, result.stderr).toBe(0) - expect(JSON.parse(result.stdout)).toEqual({ - version: ORCAD_BUN_VERSION, - code: 17, - output: true, - reattached: true, - retired: true - }) - } finally { - removeTreeSync(directory) - } - }, 20_000) -}) diff --git a/src/main/providers/local-pty-bun-posix-inspection.test.ts b/src/main/providers/local-pty-bun-posix-inspection.test.ts deleted file mode 100644 index c71e4a3fa6c4..000000000000 --- a/src/main/providers/local-pty-bun-posix-inspection.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import type { IPty } from 'node-pty' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as ProcessTableReader from '../../shared/process-table-snapshot-reader' -import type { ProcessTableRow } from '../../shared/process-table-snapshot' -import { - confirmLocalPtyForegroundProcess, - getLocalPtyForegroundProcess, - hasLocalPtyChildProcesses, - inspectLocalPtyChildProcesses -} from './local-pty-foreground-inspection' -import { LocalPtyProvider } from './local-pty-provider' -import { ptyLastRecognizedForeground, ptyProcesses, ptyShellPath } from './local-pty-provider-state' - -const scans = vi.hoisted(() => ({ full: vi.fn(), fresh: vi.fn(), strict: vi.fn() })) -vi.mock('../../shared/process-table-snapshot-reader', async (importOriginal) => ({ - ...(await importOriginal()), - getProcessTableSnapshot: scans.full, - getFreshProcessTableSnapshot: scans.fresh, - getStrictProcessTableSnapshotWithAge: scans.strict -})) -const platform = Object.getOwnPropertyDescriptor(process, 'platform')! -const id = 'bun-pane' -const provider = new LocalPtyProvider() -let rows: ProcessTableRow[] - -function row(pid: number, ppid: number, command: string, foregroundPid: number): ProcessTableRow { - return { - pid, - ppid, - command, - pgid: pid, - tpgid: foregroundPid, - stat: pid === foregroundPid ? 'Ss+' : 'Ss', - tty: 'ttys002' - } -} - -function pane(): IPty & { processNameIsSpawnFile: true } { - return { - pid: 100, - process: '/bin/zsh', - processNameIsSpawnFile: true, - cols: 80, - rows: 24, - handleFlowControl: false, - onData: () => ({ dispose() {} }), - onExit: () => ({ dispose() {} }), - resize() {}, - clear() {}, - write() {}, - kill() {}, - pause() {}, - resume() {} - } -} - -beforeEach(() => { - vi.resetAllMocks() - scans.full.mockImplementation(async () => rows) - scans.fresh.mockImplementation(async () => rows) - scans.strict.mockImplementation(async () => ({ rows, capturedAgeMs: 0 })) - ptyProcesses.set(id, pane()) - ptyShellPath.set(id, '/bin/zsh') -}) -afterEach(() => { - ptyProcesses.clear() - ptyShellPath.clear() - ptyLastRecognizedForeground.clear() - Object.defineProperty(process, 'platform', platform) - vi.restoreAllMocks() -}) - -describe.each(['darwin', 'linux'])('Bun in-process %s inspection', (host) => { - beforeEach(() => Object.defineProperty(process, 'platform', { value: host, configurable: true })) - - it('reports the actual foreground command and warns before closing a busy pane', async () => { - rows = [row(100, 1, '-zsh', 101), row(101, 100, 'vim notes.md', 101)] - expect(await provider.inspectProcess(id)).toEqual({ - foregroundProcess: 'vim', - hasChildProcesses: true, - childProcessEvidence: 'children' - }) - expect(await hasLocalPtyChildProcesses(id)).toBe(true) - expect(await confirmLocalPtyForegroundProcess(id)).toBe('vim') - expect(scans.fresh).toHaveBeenCalledOnce() - }) - - it('proves an idle shell has no children', async () => { - rows = [row(100, 1, '-zsh', 100)] - expect(await provider.inspectProcess(id)).toEqual({ - foregroundProcess: 'zsh', - hasChildProcesses: false, - childProcessEvidence: 'no-children' - }) - expect(await hasLocalPtyChildProcesses(id)).toBe(false) - }) - - it('does not mistake the login wrapper for a running user job', async () => { - ptyProcesses.set(id, { ...pane(), process: '/usr/bin/login' }) - rows = [row(100, 1, '/usr/bin/login -fp test', 101), row(101, 100, '-zsh', 101)] - expect(await provider.inspectProcess(id)).toEqual({ - foregroundProcess: 'zsh', - hasChildProcesses: false, - childProcessEvidence: 'no-children' - }) - }) - - it('preserves a cached agent when the foreground scan cannot verify it', async () => { - rows = [] - scans.full.mockRejectedValue(new Error('process table unavailable')) - scans.strict.mockRejectedValue(new Error('process table unavailable')) - ptyLastRecognizedForeground.set(id, { name: 'claude', pid: 101, at: Date.now() }) - expect(await provider.inspectProcess(id)).toEqual({ - foregroundProcess: 'claude', - hasChildProcesses: true, - childProcessEvidence: 'unverifiable' - }) - expect(await hasLocalPtyChildProcesses(id)).toBe(true) - expect(await confirmLocalPtyForegroundProcess(id)).toBeNull() - }) - - it('returns uncertainty when the process table has no pane root', async () => { - rows = [row(900, 1, '-zsh', 900)] - expect(await getLocalPtyForegroundProcess(id)).toBeNull() - expect(await inspectLocalPtyChildProcesses(id)).toBe('unverifiable') - expect(await hasLocalPtyChildProcesses(id)).toBe(true) - }) - - it('does not resurrect an agent cache after replacement during fingerprint capture', async () => { - rows = [row(100, 1, '-zsh', 101), row(101, 100, 'node /usr/local/bin/claude', 101)] - scans.full - .mockImplementationOnce(async () => rows) - .mockImplementationOnce(async () => { - ptyProcesses.set(id, { ...pane(), pid: 999 }) - return rows - }) - expect(await getLocalPtyForegroundProcess(id)).toBeNull() - expect(ptyLastRecognizedForeground.has(id)).toBe(false) - }) - - it('does not combine an old foreground with a replacement pane during a child scan', async () => { - rows = [row(100, 1, '-zsh', 101), row(101, 100, 'vim notes.md', 101)] - scans.strict.mockImplementationOnce(async () => { - ptyProcesses.set(id, { ...pane(), pid: 999 }) - return { rows, capturedAgeMs: 0 } - }) - expect(await provider.inspectProcess(id)).toEqual({ - foregroundProcess: null, - hasChildProcesses: true, - childProcessEvidence: 'unverifiable' - }) - }) -}) diff --git a/src/main/providers/local-pty-bun-windows-identity.test.ts b/src/main/providers/local-pty-bun-windows-identity.test.ts deleted file mode 100644 index 434445790462..000000000000 --- a/src/main/providers/local-pty-bun-windows-identity.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { ptyProcesses, ptyShellPath } from './local-pty-provider-state' -import { - confirmLocalPtyShellForeground, - inspectLocalPtyChildProcesses -} from './local-pty-foreground-inspection' -import { getLocalPtyCwd, sendLocalPtySignal } from './local-pty-session-operations' - -const { confirm, cwd, membership } = vi.hoisted(() => ({ - confirm: vi.fn(), - cwd: vi.fn(), - membership: vi.fn() -})) -vi.mock('./agent-foreground-process', () => ({ - confirmShellForegroundProcess: confirm, - resolveAgentForegroundProcessWithAvailability: vi.fn() -})) -vi.mock('./process-cwd', () => ({ resolveProcessCwd: cwd })) -vi.mock('./windows-pty-job-membership', () => ({ - readWindowsPtyJobProcessIds: membership, - isWindowsPtyJobReadable: () => true -})) - -const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! -beforeEach(() => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - confirm.mockResolvedValue(true) - cwd.mockResolvedValue('C:\\work') -}) -afterEach(() => { - ptyProcesses.clear() - ptyShellPath.clear() - Object.defineProperty(process, 'platform', originalPlatform) - vi.restoreAllMocks() -}) - -describe('Bun in-process Windows shell identity', () => { - it('uses the child shell for cwd and foreground checks while signaling the owned job', async () => { - const proc = { - pid: 1200, - shellProcessId: 1201, - jobRootProcessIsWrapper: true as const, - processNameIsSpawnFile: true as const, - process: 'cmd.exe', - cols: 80, - rows: 24, - handleFlowControl: false, - onData: () => ({ dispose() {} }), - onExit: () => ({ dispose() {} }), - write() {}, - clear() {}, - pause() {}, - resume() {}, - resize() {}, - kill() {}, - signalProcess: vi.fn() - } - ptyProcesses.set('gated-shell', proc) - const shellPath = 'C:\\Windows\\System32\\cmd.exe' - ptyShellPath.set('gated-shell', shellPath) - const kill = vi.spyOn(process, 'kill').mockReturnValue(true) - expect(await getLocalPtyCwd('gated-shell')).toBe('C:\\work') - expect(cwd).toHaveBeenCalledWith(1201) - expect(await confirmLocalPtyShellForeground('gated-shell')).toBe(true) - expect(confirm).toHaveBeenCalledWith(1201, shellPath, expect.any(Object)) - await sendLocalPtySignal('gated-shell', 'SIGTERM') - expect(proc.signalProcess).toHaveBeenCalledWith('SIGTERM') - expect(kill).not.toHaveBeenCalled() - membership.mockReturnValue(new Set([1201])) - expect(await inspectLocalPtyChildProcesses('gated-shell')).toBe('no-children') - membership.mockReturnValue(new Set([1201, 1202])) - expect(await inspectLocalPtyChildProcesses('gated-shell')).toBe('children') - membership.mockReturnValue(null) - expect(await inspectLocalPtyChildProcesses('gated-shell')).toBe('unverifiable') - Reflect.deleteProperty(proc, 'shellProcessId') - cwd.mockClear() - expect(await getLocalPtyCwd('gated-shell')).toBe('') - expect(cwd).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/providers/local-pty-launch-plan.ts b/src/main/providers/local-pty-launch-plan.ts index 17351325300d..c8b784e7cb35 100644 --- a/src/main/providers/local-pty-launch-plan.ts +++ b/src/main/providers/local-pty-launch-plan.ts @@ -1,5 +1,4 @@ import { win32 as pathWin32 } from 'node:path' -import { canUseBunPty } from '../daemon/pty-subprocess/bun-pty-process-capabilities' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' import { resolveWindowsGitBashShellPath } from '../git-bash' @@ -77,9 +76,7 @@ function finalizeLocalPtyLaunchPlan( windowsFallbackAttempts?: ReturnType } ): LocalPtyLaunchPlan { - if (!canUseBunPty()) { - ensureNodePtySpawnHelperExecutable() - } + ensureNodePtySpawnHelperExecutable() if (seed.args.prevalidatedCwd !== shell.validationCwd) { validateWorkingDirectory(shell.validationCwd) } diff --git a/src/main/providers/local-pty-provider-shell-readiness.test.ts b/src/main/providers/local-pty-provider-shell-readiness.test.ts index 0457a6e170f0..6a46261e7931 100644 --- a/src/main/providers/local-pty-provider-shell-readiness.test.ts +++ b/src/main/providers/local-pty-provider-shell-readiness.test.ts @@ -275,23 +275,6 @@ describe('LocalPtyProvider', () => { } }) - it.each([ - ['after the ready marker', ['\x1b]777;orca-shell-ready\x07', '\x1b[?2004hfish> ']], - ['after the ESC introducer', ['\x1b]777;orca-shell-ready\x07\x1b', '[?2004hfish> ']] - ])('preserves Fish bracketed-paste output split %s', async (_boundary, chunks) => { - process.env.SHELL = '/usr/bin/fish' - const received: string[] = [] - provider.configure({ onData: (_id, data) => received.push(data) }) - - await provider.spawn({ cols: 80, rows: 24, command: 'printf ready' }) - const dataCallback = mockProc.onData.mock.calls[0]?.[0] as (data: string) => void - for (const chunk of chunks) { - dataCallback(chunk) - } - - expect(received.join('')).toBe('\x1b[?2004hfish> ') - }) - it('releases held marker-prefix bytes when local shell readiness times out', async () => { vi.useFakeTimers() const onData = vi.fn() diff --git a/src/main/providers/local-pty-provider-spawn-session.test.ts b/src/main/providers/local-pty-provider-spawn-session.test.ts index cf523a469fca..d283cc188b8b 100644 --- a/src/main/providers/local-pty-provider-spawn-session.test.ts +++ b/src/main/providers/local-pty-provider-spawn-session.test.ts @@ -53,10 +53,6 @@ vi.mock('node-pty', () => ({ spawn: spawnMock })) -vi.mock('../daemon/pty-subprocess/bun-pty-process-capabilities', () => ({ - canUseBunPty: () => false -})) - vi.mock('./macos-tcc-login-shell', async (importOriginal) => ({ ...(await importOriginal()), prepareMacosTccLoginShell: prepareMacosTccLoginShellMock diff --git a/src/main/providers/local-pty-runtime-spawn.ts b/src/main/providers/local-pty-runtime-spawn.ts index 7d3c26d90ade..0989bd46a02a 100644 --- a/src/main/providers/local-pty-runtime-spawn.ts +++ b/src/main/providers/local-pty-runtime-spawn.ts @@ -1,5 +1,3 @@ -import { canUseBunPty, spawnBunPty } from '../daemon/pty-subprocess/bun-pty-process' -import { spawnNativeDaemonPty } from '../daemon/pty-subprocess/native-pty-spawn' import { spawnShellWithFallback, type ShellSpawnParams, @@ -10,35 +8,8 @@ type LocalPtySpawn = ( params: Omit & { signal?: AbortSignal } ) => ShellSpawnResult | Promise -/** Degraded daemon routing uses the same runtime as the packaged host. */ +/** Degraded daemon routing loads node-pty lazily, as the daemon does. */ export async function loadLocalPtyRuntimeSpawn(): Promise { - if (!canUseBunPty()) { - const pty = await import('node-pty') - return (params) => spawnShellWithFallback({ ...params, ptySpawn: pty.spawn }) - } - if (process.platform === 'win32') { - return (params) => - spawnNativeDaemonPty({ - ...params, - spawnCwd: params.cwd, - windowsFallbackAttempts: params.windowsFallbackAttempts ?? [] - }) - } - return (params) => - spawnShellWithFallback({ - ...params, - ptySpawn(file, args = [], options = {}) { - if (!Array.isArray(args)) { - throw new Error('POSIX PTY arguments must be an array') - } - return spawnBunPty({ - file, - args, - cwd: options.cwd ?? params.cwd, - env: params.env, - cols: options.cols ?? params.cols, - rows: options.rows ?? params.rows - }) - } - }) + const pty = await import('node-pty') + return (params) => spawnShellWithFallback({ ...params, ptySpawn: pty.spawn }) } diff --git a/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts b/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts index af0fc4a5deb1..8b061c5d6423 100644 --- a/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts +++ b/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts @@ -219,37 +219,6 @@ describePosix('local PTY shell-ready launch config', () => { } }) - it('uses inherited ORCA_ORIG_ZDOTDIR when ZDOTDIR is an Orca wrapper dir', async () => { - const previousZdotdir = process.env.ZDOTDIR - const previousOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR - const previousHome = process.env.HOME - const userZdotdir = makeUserZdotdir(userDataPath, '.config', 'zsh') - process.env.ZDOTDIR = '/some/other/orca/shell-ready/zsh' - process.env.ORCA_ORIG_ZDOTDIR = userZdotdir - process.env.HOME = userDataPath - try { - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBe(userZdotdir) - } finally { - if (previousZdotdir === undefined) { - delete process.env.ZDOTDIR - } else { - process.env.ZDOTDIR = previousZdotdir - } - if (previousOrigZdotdir === undefined) { - delete process.env.ORCA_ORIG_ZDOTDIR - } else { - process.env.ORCA_ORIG_ZDOTDIR = previousOrigZdotdir - } - if (previousHome === undefined) { - delete process.env.HOME - } else { - process.env.HOME = previousHome - } - } - }) - it('falls back to HOME when inherited ORCA_ORIG_ZDOTDIR points at a wrapper dir', async () => { const previousZdotdir = process.env.ZDOTDIR const previousOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR @@ -486,29 +455,6 @@ describePosix('local PTY shell-ready launch config', () => { } }) - it('rejects inherited ZDOTDIR ending in /shell-ready/zsh even with a trailing slash', async () => { - const previousZdotdir = process.env.ZDOTDIR - const previousHome = process.env.HOME - process.env.ZDOTDIR = '/some/other/orca/shell-ready/zsh/' - process.env.HOME = '/Users/alice' - try { - const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellReadyLaunchConfig('/bin/zsh') - expect(config.env.ORCA_ORIG_ZDOTDIR).toBeUndefined() - } finally { - if (previousZdotdir === undefined) { - delete process.env.ZDOTDIR - } else { - process.env.ZDOTDIR = previousZdotdir - } - if (previousHome === undefined) { - delete process.env.HOME - } else { - process.env.HOME = previousHome - } - } - }) - it('falls back to HOME when ZDOTDIR is only slashes (e.g. "/")', async () => { const previousZdotdir = process.env.ZDOTDIR const previousHome = process.env.HOME diff --git a/src/main/providers/ssh-git-provider-worktree.test.ts b/src/main/providers/ssh-git-provider-worktree.test.ts index 03722c93a529..cc9a80241047 100644 --- a/src/main/providers/ssh-git-provider-worktree.test.ts +++ b/src/main/providers/ssh-git-provider-worktree.test.ts @@ -140,20 +140,53 @@ describe('SshGitProvider', () => { expect(result).toEqual({ clean: false }) }) - it('refreshLocalBaseRefForWorktreeCreate sends the narrow refresh request', async () => { - await provider.refreshLocalBaseRefForWorktreeCreate({ + it('refreshLocalBaseRefForWorktreeCreate sends the narrow refresh request and returns its outcome', async () => { + const refs = { repoPath: '/home/user/repo', fullRef: 'refs/heads/main', - remoteTrackingRef: 'refs/remotes/origin/main', + remoteTrackingRef: 'refs/remotes/origin/main' + } + mux.request.mockResolvedValueOnce({ + status: 'skipped_dirty_worktree', ownerWorktreePath: '/home/user/repo' }) - expect(mux.request).toHaveBeenCalledWith('git.refreshLocalBaseRefForWorktreeCreate', { - repoPath: '/home/user/repo', - fullRef: 'refs/heads/main', - remoteTrackingRef: 'refs/remotes/origin/main', + await expect(provider.refreshLocalBaseRefForWorktreeCreate(refs)).resolves.toEqual({ + status: 'skipped_dirty_worktree', ownerWorktreePath: '/home/user/repo' }) + expect(mux.request).toHaveBeenCalledWith('git.refreshLocalBaseRefForWorktreeCreate', refs) + }) + + it('refreshLocalBaseRefForWorktreeCreate reads a malformed relay reply as an error', async () => { + mux.request.mockResolvedValueOnce(undefined) + + await expect( + provider.refreshLocalBaseRefForWorktreeCreate({ + repoPath: '/home/user/repo', + fullRef: 'refs/heads/main', + remoteTrackingRef: 'refs/remotes/origin/main' + }) + ).resolves.toEqual({ status: 'skipped_error' }) + }) + + it('getLocalBaseRefFastForwardableBehind asks the relay to inspect without moving anything', async () => { + const refs = { + repoPath: '/home/user/repo', + fullRef: 'refs/heads/main', + remoteTrackingRef: 'refs/remotes/origin/main' + } + mux.request + .mockResolvedValueOnce({ status: 'behind', behind: 4, localOid: 'a', remoteOid: 'b' }) + .mockResolvedValueOnce({ status: 'skipped_dirty_worktree', ownerWorktreePath: '/x' }) + + await expect(provider.getLocalBaseRefFastForwardableBehind(refs)).resolves.toBe(4) + await expect(provider.getLocalBaseRefFastForwardableBehind(refs)).resolves.toBeUndefined() + expect(mux.request).toHaveBeenCalledWith('git.inspectLocalBaseRefForWorktreeCreate', refs) + expect(mux.request).not.toHaveBeenCalledWith( + 'git.refreshLocalBaseRefForWorktreeCreate', + expect.anything() + ) }) it('worktreeIsClean falls back to git.status for old relays', async () => { diff --git a/src/main/providers/ssh-git-worktree-provider.ts b/src/main/providers/ssh-git-worktree-provider.ts index 17e5b273de76..4e1e0f3a0578 100644 --- a/src/main/providers/ssh-git-worktree-provider.ts +++ b/src/main/providers/ssh-git-worktree-provider.ts @@ -5,6 +5,12 @@ import { CapabilityProbeCache } from '../../shared/capability-probe-cache' import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe' import { assertAuthoritativeWorktreeCatalog } from '../../shared/worktree/worktree-catalog-availability' import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' +import { + parseLocalBaseBranchFastForwardOutcome, + readFastForwardableBehindCount, + type LocalBaseBranchFastForwardOutcome, + type LocalBaseBranchRefs +} from '../../shared/worktree/local-base-branch-fast-forward' import { SshGitReviewHeadProvider } from './ssh-git-review-head-provider' const WORKTREE_IS_CLEAN_CAPABILITY = 'git.worktreeIsClean' as const @@ -134,16 +140,24 @@ export class SshGitWorktreeProvider extends SshGitReviewHeadProvider { ) } - async refreshLocalBaseRefForWorktreeCreate(args: { - repoPath: string - fullRef: string - remoteTrackingRef: string - ownerWorktreePath?: string - checkOnly?: boolean - }): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.refreshLocalBaseRefForWorktreeCreate', args) - }) + /** The relay inspects and moves the branch host-side, one run per branch at a time. */ + async refreshLocalBaseRefForWorktreeCreate( + args: LocalBaseBranchRefs + ): Promise { + return this.runWithGitReadInvalidation(async () => + parseLocalBaseBranchFastForwardOutcome( + await this.mux.request('git.refreshLocalBaseRefForWorktreeCreate', args) + ) + ) + } + + /** Commits local is behind when the relay could fast-forward it; read-only. */ + async getLocalBaseRefFastForwardableBehind( + args: LocalBaseBranchRefs + ): Promise { + return readFastForwardableBehindCount( + await this.mux.request('git.inspectLocalBaseRefForWorktreeCreate', args) + ) } async renameCurrentBranch(worktreePath: string, newBranch: string): Promise { diff --git a/src/main/providers/windows-agent-foreground-command-line.test.ts b/src/main/providers/windows-agent-foreground-command-line.test.ts new file mode 100644 index 000000000000..1c2599dea444 --- /dev/null +++ b/src/main/providers/windows-agent-foreground-command-line.test.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table' +import { resetWindowsProcessRowsSnapshotForTests } from './windows-foreground-process-rows' +import { resolveWindowsAgentForegroundCommandLine } from './windows-agent-foreground-command-line' + +type NativeProcessRow = { pid: number; ppid: number; name: string; commandLine?: string } + +const getAllProcessesMock = vi.fn() + +// Why: the native reader rejects a table without the querying process (a blocked snapshot). +function mockWindowsRows(rows: NativeProcessRow[]): void { + getAllProcessesMock.mockImplementation((cb: (snapshot: NativeProcessRow[]) => void) => { + cb([{ pid: process.pid, ppid: 0, name: 'vitest.exe', commandLine: 'vitest' }, ...rows]) + }) +} + +describe('resolveWindowsAgentForegroundCommandLine', () => { + let platform: PropertyDescriptor | undefined + + beforeEach(() => { + getAllProcessesMock.mockReset() + resetWindowsProcessRowsSnapshotForTests() + __setWindowsProcessTreeLoaderForTests(() => ({ + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, + getAllProcesses: getAllProcessesMock + })) + platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + }) + + afterEach(() => { + __setWindowsProcessTreeLoaderForTests() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + }) + + it("returns the pane's foreground agent command line from the native table", async () => { + mockWindowsRows([ + { pid: 100, ppid: 99, name: 'pwsh.exe', commandLine: 'pwsh.exe -NoLogo' }, + { + pid: 101, + ppid: 100, + name: 'opencode.exe', + commandLine: '"C:\\Program Files\\OpenCode\\opencode.exe" run fix the bug' + } + ]) + + await expect(resolveWindowsAgentForegroundCommandLine(100, 'opencode.exe')).resolves.toBe( + '"C:\\Program Files\\OpenCode\\opencode.exe" run fix the bug' + ) + }) + + it('returns null when the pane holds no such agent or the table misses the pane', async () => { + mockWindowsRows([{ pid: 100, ppid: 99, name: 'pwsh.exe', commandLine: 'pwsh.exe' }]) + await expect(resolveWindowsAgentForegroundCommandLine(100, 'opencode.exe')).resolves.toBeNull() + resetWindowsProcessRowsSnapshotForTests() + await expect(resolveWindowsAgentForegroundCommandLine(555, 'opencode.exe')).resolves.toBeNull() + }) +}) diff --git a/src/main/providers/windows-agent-foreground-command-line.ts b/src/main/providers/windows-agent-foreground-command-line.ts new file mode 100644 index 000000000000..a0858642d77a --- /dev/null +++ b/src/main/providers/windows-agent-foreground-command-line.ts @@ -0,0 +1,21 @@ +import { resolveWindowsForegroundIdentity } from './windows-agent-foreground-process' +import { queryWindowsPaneProcessInventory } from './windows-foreground-process-rows' + +/** Command line of the pane's foreground agent process, from one fresh native table read. */ +export async function resolveWindowsAgentForegroundCommandLine( + shellPid: number, + foregroundProcess: string +): Promise { + const inventory = await queryWindowsPaneProcessInventory(shellPid, { fresh: true }) + if (!inventory) { + return null + } + const { processId } = resolveWindowsForegroundIdentity( + inventory.candidates, + foregroundProcess, + undefined + ) + return processId === undefined + ? null + : (inventory.candidates.find((candidate) => candidate.pid === processId)?.command ?? null) +} diff --git a/src/main/providers/windows-agent-foreground-process.ts b/src/main/providers/windows-agent-foreground-process.ts index 8ffba73c24d9..8a439562f718 100644 --- a/src/main/providers/windows-agent-foreground-process.ts +++ b/src/main/providers/windows-agent-foreground-process.ts @@ -143,7 +143,7 @@ function windowsCandidatesContainRecognizedAgent( ) } -function resolveWindowsForegroundIdentity( +export function resolveWindowsForegroundIdentity( candidates: readonly WindowsProcessCandidate[], fallbackProcess: string, contextPaths: readonly string[] | undefined diff --git a/src/main/pty/codex-preflight-profile-path-rewrite.test.ts b/src/main/pty/codex-preflight-profile-path-rewrite.test.ts index 4649b5636a38..06b6123c5612 100644 --- a/src/main/pty/codex-preflight-profile-path-rewrite.test.ts +++ b/src/main/pty/codex-preflight-profile-path-rewrite.test.ts @@ -54,8 +54,12 @@ function buildFixture(options: { aliasCodex?: boolean } = {}): Fixture { mkdirSync(codexDir, { recursive: true }) mkdirSync(homePath, { recursive: true }) - // The CLI Orca ships, at the absolute path Orca controls. - writeStub(getBundledLauncherPath(process.platform, resourcesPath) as string, intendedMarker) + // The CLI Orca ships, at the absolute path Orca controls; only WSL panes run it. + const launcherPath = getBundledLauncherPath('win32', resourcesPath) + if (!launcherPath) { + throw new Error('Windows has a bundled launcher path') + } + writeStub(launcherPath, intendedMarker) // The impostor a user's own bin directory could hold under every CLI name Orca uses. for (const name of ['orca', 'orca-ide', 'orca-dev']) { writeStub(join(hijackDir, name), hijackMarker) @@ -126,11 +130,12 @@ describe.skipIf(!bashAvailable)('Codex preflight under a profile-rewritten PATH' const preflightCommand = resolveCodexShellLaunchPreflightCommand({ hooksEnabled: true, isPackaged: true, + isWsl: true, managedHomePath: join(fixture.root, 'codex-home'), - userDataPath: join(fixture.root, 'user-data'), - resourcesPath: fixture.resourcesPath + resourcesPath: fixture.resourcesPath, + platform: 'win32' }) - expect(preflightCommand).toBe(getBundledLauncherPath(process.platform, fixture.resourcesPath)) + expect(preflightCommand).toBe(getBundledLauncherPath('win32', fixture.resourcesPath)) launchCodexThroughRcfile(fixture, preflightCommand as string) @@ -148,9 +153,10 @@ describe.skipIf(!bashAvailable)('Codex preflight under a profile-rewritten PATH' const preflightCommand = resolveCodexShellLaunchPreflightCommand({ hooksEnabled: true, isPackaged: true, + isWsl: true, managedHomePath: join(fixture.root, 'codex-home'), - userDataPath: join(fixture.root, 'user-data'), - resourcesPath: fixture.resourcesPath + resourcesPath: fixture.resourcesPath, + platform: 'win32' }) launchCodexThroughRcfile(fixture, preflightCommand as string) diff --git a/src/main/pty/codex-shell-launch-preflight.test.ts b/src/main/pty/codex-shell-launch-preflight.test.ts index 72fbc5c14a51..2586b2a7c982 100644 --- a/src/main/pty/codex-shell-launch-preflight.test.ts +++ b/src/main/pty/codex-shell-launch-preflight.test.ts @@ -412,167 +412,86 @@ describe('PowerShell Codex shell launch preflight', () => { }) describe('Codex shell launch preflight command', () => { - function makeCliRoot(): { root: string; userDataPath: string; resourcesPath: string } { + function makeCliRoot(): { resourcesPath: string } { const root = mkdtempSync(join(tmpdir(), 'orca-codex-preflight-cli-')) roots.push(root) - const userDataPath = join(root, 'user-data') const resourcesPath = join(root, 'resources') - mkdirSync(join(userDataPath, 'cli', 'bin'), { recursive: true }) mkdirSync(join(resourcesPath, 'bin'), { recursive: true }) - return { root, userDataPath, resourcesPath } + return { resourcesPath } } - it.each([ - { platform: 'darwin' as const, bundled: 'orca' }, - { platform: 'linux' as const, bundled: 'orca-ide' }, - { platform: 'win32' as const, bundled: 'orca.exe' } - ])('carries the verified bundled $platform launcher as an absolute path', (config) => { - const { userDataPath, resourcesPath } = makeCliRoot() - const launcherPath = join(resourcesPath, 'bin', config.bundled) - writeExecutable(launcherPath, '#!/bin/sh\nexit 0\n') - - expect( - resolveCodexShellLaunchPreflightCommand({ - hooksEnabled: true, - isPackaged: true, - managedHomePath: '/managed/home', - userDataPath, - resourcesPath, - platform: config.platform - }) - ).toBe(launcherPath) - }) + function wslOptions(resourcesPath: string | null) { + return { + hooksEnabled: true, + isPackaged: true, + isWsl: true, + managedHomePath: '/home/jin/.local/share/orca/codex-runtime-home/home', + resourcesPath, + platform: 'win32' as const + } + } - it('carries the verified dev launcher as an absolute path', () => { - const { userDataPath, resourcesPath } = makeCliRoot() - const launcherPath = join(userDataPath, 'cli', 'bin', 'orca-dev') - writeExecutable(launcherPath, '#!/bin/sh\nexit 0\n') + it.each([ + { platform: 'darwin' as const, bundled: 'orca', isPackaged: true }, + { platform: 'linux' as const, bundled: 'orca-ide', isPackaged: true }, + { platform: 'win32' as const, bundled: 'orca.exe', isPackaged: true }, + { platform: 'darwin' as const, bundled: 'orca', isPackaged: false } + ])( + 'gives a native $platform pane no preflight: the app prepares its Codex home (packaged $isPackaged)', + (config) => { + const { resourcesPath } = makeCliRoot() + writeExecutable(join(resourcesPath, 'bin', config.bundled), '#!/bin/sh\nexit 0\n') - expect( - resolveCodexShellLaunchPreflightCommand({ - hooksEnabled: true, - isPackaged: false, - managedHomePath: '/managed/home', - userDataPath, - resourcesPath, - platform: 'darwin' - }) - ).toBe(launcherPath) - }) + expect( + resolveCodexShellLaunchPreflightCommand({ + hooksEnabled: true, + isPackaged: config.isPackaged, + managedHomePath: '/managed/home', + resourcesPath, + platform: config.platform + }) + ).toBeNull() + } + ) - it('carries the packaged Windows launcher for WSLENV path translation', () => { - const { userDataPath, resourcesPath } = makeCliRoot() + it('carries the packaged Windows launcher, as an absolute path, for WSLENV path translation', () => { + const { resourcesPath } = makeCliRoot() const launcherPath = join(resourcesPath, 'bin', 'orca.exe') writeExecutable(launcherPath, '#!/bin/sh\nexit 0\n') - expect( - resolveCodexShellLaunchPreflightCommand({ - hooksEnabled: true, - isPackaged: true, - isWsl: true, - managedHomePath: '/home/jin/.local/share/orca/codex-runtime-home/home', - userDataPath, - resourcesPath, - platform: 'win32' - }) - ).toBe(launcherPath) - }) + const command = resolveCodexShellLaunchPreflightCommand(wslOptions(resourcesPath)) - it('never returns an unqualified command name that a profile-rewritten PATH could hijack', () => { - const { userDataPath, resourcesPath } = makeCliRoot() - writeExecutable(join(resourcesPath, 'bin', 'orca'), '#!/bin/sh\nexit 0\n') - writeExecutable(join(userDataPath, 'cli', 'bin', 'orca-dev'), '#!/bin/sh\nexit 0\n') - - for (const isPackaged of [true, false]) { - const command = resolveCodexShellLaunchPreflightCommand({ - hooksEnabled: true, - isPackaged, - managedHomePath: '/managed/home', - userDataPath, - resourcesPath, - platform: 'darwin' - }) - expect(command).not.toBeNull() - expect(isAbsolute(command as string)).toBe(true) - } + expect(command).toBe(launcherPath) + expect(isAbsolute(command as string)).toBe(true) }) it.each([ { label: 'the launcher file is missing', create: null }, { label: 'the launcher path is a directory', create: 'directory' as const } ])('skips the preflight when $label', (config) => { - const { userDataPath, resourcesPath } = makeCliRoot() - const launcherPath = join(resourcesPath, 'bin', 'orca') + const { resourcesPath } = makeCliRoot() if (config.create === 'directory') { - mkdirSync(launcherPath) + mkdirSync(join(resourcesPath, 'bin', 'orca.exe')) } - expect( - resolveCodexShellLaunchPreflightCommand({ - hooksEnabled: true, - isPackaged: true, - managedHomePath: '/managed/home', - userDataPath, - resourcesPath, - platform: 'darwin' - }) - ).toBeNull() + expect(resolveCodexShellLaunchPreflightCommand(wslOptions(resourcesPath))).toBeNull() }) - it.skipIf(process.platform === 'win32')( - 'skips the preflight when the launcher is not executable', - () => { - const { userDataPath, resourcesPath } = makeCliRoot() - const launcherPath = join(resourcesPath, 'bin', 'orca') - writeFileSync(launcherPath, '#!/bin/sh\nexit 0\n') - chmodSync(launcherPath, 0o644) + it('skips the preflight when the packaged build exposes no resources root', () => { + expect(resolveCodexShellLaunchPreflightCommand(wslOptions(null))).toBeNull() + }) + + it.each([{ hooksEnabled: false }, { isPackaged: false }, { managedHomePath: null }])( + 'does not enable an unsupported WSL preflight for %o', + (override) => { + const { resourcesPath } = makeCliRoot() + writeExecutable(join(resourcesPath, 'bin', 'orca.exe'), '#!/bin/sh\nexit 0\n') expect( - resolveCodexShellLaunchPreflightCommand({ - hooksEnabled: true, - isPackaged: true, - managedHomePath: '/managed/home', - userDataPath, - resourcesPath, - platform: 'darwin' - }) + resolveCodexShellLaunchPreflightCommand({ ...wslOptions(resourcesPath), ...override }) ).toBeNull() } ) - - it('skips the preflight when the packaged build exposes no resources root', () => { - const { userDataPath } = makeCliRoot() - - expect( - resolveCodexShellLaunchPreflightCommand({ - hooksEnabled: true, - isPackaged: true, - managedHomePath: '/managed/home', - userDataPath, - resourcesPath: null, - platform: 'darwin' - }) - ).toBeNull() - }) - - it.each([ - { hooksEnabled: false, isWsl: false, managedHomePath: '/managed/home' }, - { hooksEnabled: true, isWsl: true, managedHomePath: '/managed/home', isPackaged: false }, - { hooksEnabled: true, isWsl: false, managedHomePath: null } - ])('does not enable an unsupported preflight for %o', (options) => { - const { userDataPath, resourcesPath } = makeCliRoot() - writeExecutable(join(resourcesPath, 'bin', 'orca'), '#!/bin/sh\nexit 0\n') - - expect( - resolveCodexShellLaunchPreflightCommand({ - ...options, - isPackaged: options.isPackaged ?? true, - userDataPath, - resourcesPath, - platform: 'darwin' - }) - ).toBeNull() - }) }) // Why: the resolved value is now an absolute path, and app bundles (macOS) and diff --git a/src/main/pty/codex-shell-launch-preflight.ts b/src/main/pty/codex-shell-launch-preflight.ts index b6c17074d79c..9a119e5d8aa6 100644 --- a/src/main/pty/codex-shell-launch-preflight.ts +++ b/src/main/pty/codex-shell-launch-preflight.ts @@ -1,17 +1,11 @@ import { accessSync, constants, statSync } from 'node:fs' -import { join } from 'node:path' import { getBundledLauncherPath } from '../cli/bundled-cli-launcher-path' -const DEV_LAUNCHER_DIR = ['cli', 'bin'] -const DEV_COMMAND_NAME = 'orca-dev' - export type CodexShellLaunchPreflightCommandOptions = { hooksEnabled: boolean isPackaged: boolean isWsl?: boolean managedHomePath: string | null - /** Where the dev launcher is written; `join(userDataPath, 'cli', 'bin')` is also what managed dev PTYs prepend to PATH. */ - userDataPath: string /** Packaged app resources root; the bundled launcher lives under it. */ resourcesPath?: string | null /** Test seam. */ @@ -19,6 +13,7 @@ export type CodexShellLaunchPreflightCommandOptions = { } /** Absolute path of the Orca CLI the preflight must execute, or null to skip it. + * Only a WSL pane gets one: the app prepares every native Codex home itself. * * Why absolute: the value rides in ORCA_CODEX_LAUNCH_PREFLIGHT and is invoked * from the codex() wrapper, which shell-ready emits *after* the user's profile @@ -29,27 +24,20 @@ export type CodexShellLaunchPreflightCommandOptions = { export function resolveCodexShellLaunchPreflightCommand( options: CodexShellLaunchPreflightCommandOptions ): string | null { - if (!options.hooksEnabled || !options.managedHomePath) { - return null - } const platform = options.platform ?? process.platform - const candidate = options.isPackaged - ? options.resourcesPath - ? getBundledLauncherPath(platform, options.resourcesPath) - : null - : join( - options.userDataPath, - ...DEV_LAUNCHER_DIR, - platform === 'win32' ? `${DEV_COMMAND_NAME}.cmd` : DEV_COMMAND_NAME - ) - if (!candidate || !isExecutableFileOnDisk(candidate, platform)) { + // Why: WSLENV /p translates the verified Windows launcher with the distro's configured automount root. + if ( + !options.hooksEnabled || + !options.managedHomePath || + !options.isWsl || + !options.isPackaged || + platform !== 'win32' || + !options.resourcesPath + ) { return null } - if (!options.isWsl) { - return candidate - } - // Why: WSLENV /p translates the verified Windows launcher with the distro's configured automount root. - return platform === 'win32' && options.isPackaged ? candidate : null + const candidate = getBundledLauncherPath(platform, options.resourcesPath) + return candidate && isExecutableFileOnDisk(candidate, platform) ? candidate : null } function isExecutableFileOnDisk(path: string, platform: NodeJS.Platform): boolean { diff --git a/src/main/rate-limits/antigravity-usage-command.ts b/src/main/rate-limits/antigravity-usage-command.ts new file mode 100644 index 000000000000..7d58f98cf57d --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-command.ts @@ -0,0 +1,59 @@ +import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' + +/** + * The quota read is a slash command run in print mode. + * + * Why print mode and not an HTTP call: agy keeps its Google credential in the OS keyring and mints + * its own access token against `daily-cloudcode-pa`, so nothing outside agy can authenticate the + * quota endpoint. Verified on agy 1.2.11: the call spends no quota and starts no conversation + * (`num_turns: 0`, every token counter 0, empty `conversation_id`). + * + * `/usage` over `/quota`: both resolve to the same `usage` command, and `/usage` is the spelling agy + * lists in its own help. + */ +export const ANTIGRAVITY_USAGE_ARGS: readonly string[] = [ + '-p', + '/usage', + '--output-format', + 'json', + // Why bound it inside agy too: the process timeout below kills a hung child, but agy's own + // deadline lets it exit cleanly and print a diagnostic instead of dying mid-write. + '--print-timeout', + '20s' + // Do NOT add --disable-slash-commands here. It stops agy expanding `/usage` as a command, so the + // text is sent to the model as an ordinary prompt: the call then starts a conversation, spends + // quota, and on an account near its limit returns RESOURCE_EXHAUSTED (429) instead of a reading. + // Verified against agy 1.2.11 — the flag turned a free metadata read into a billed model turn. +] + +/** + * How long the child may run before Orca kills it. + * + * Observed cost on a warm macOS install is 2.1–2.6 s (three consecutive runs), which is the CLI + * starting its language server and refreshing the quota. The ceiling is generous because a cold + * start also pays a binary self-check, and the fetch runs on its own promise so a slow read delays + * nothing else in the cycle. + */ +export const ANTIGRAVITY_USAGE_TIMEOUT_MS = 30_000 + +/** Cap on captured output; the envelope is a single JSON line well under a kilobyte. */ +export const ANTIGRAVITY_USAGE_MAX_OUTPUT_BYTES = 512 * 1024 + +/** The command name Orca already uses to detect Antigravity, so both agree on the binary. */ +export function antigravityCommandName(): string { + return TUI_AGENT_CONFIG.antigravity.detectCmd +} + +/** + * Why the args are never appended to a configured launch command: a user's Antigravity launch + * command may carry its own flags, a wrapper script, or a shell pipeline, and appending `-p /usage` + * to that either runs the wrong program or feeds the slash command to the wrong argv slot. The quota + * read resolves the plain executable itself instead. + */ +export function isPlainAntigravityExecutable(command: string): boolean { + const trimmed = command.trim() + if (trimmed.length === 0) { + return false + } + return !/[\s"'|&;<>$`()]/.test(trimmed) +} diff --git a/src/main/rate-limits/antigravity-usage-fetcher.test.ts b/src/main/rate-limits/antigravity-usage-fetcher.test.ts new file mode 100644 index 000000000000..f05fc7bc7a5d --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-fetcher.test.ts @@ -0,0 +1,250 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + fetchAntigravityRateLimits, + resetAntigravityUsageSupportForTests +} from './antigravity-usage-fetcher' +import { ANTIGRAVITY_USAGE_ARGS } from './antigravity-usage-command' +import type { ProcessResult } from '../../shared/child-process/process-spec' + +const USAGE_ENVELOPE = JSON.stringify({ + conversation_id: '', + status: 'SUCCESS', + command: { + name: 'usage', + data: { + description: 'Within each group, models share a weekly limit.', + groups: [ + { + name: 'Gemini Models', + buckets: [ + { + id: 'gemini-weekly', + name: 'Weekly Limit Remaining', + window: 'weekly', + remaining_fraction: 0.4, + reset_time: '2026-10-07T08:08:35Z' + } + ] + } + ] + } + } +}) + +function processResult(overrides: Partial = {}): ProcessResult { + return { code: 0, signal: null, stdout: '', stderr: '', timedOut: false, ...overrides } +} + +function harness( + options: { + result?: ProcessResult + runCommand?: ReturnType + program?: string | null + env?: NodeJS.ProcessEnv + } = {} +) { + const runCommand = + options.runCommand ?? vi.fn().mockResolvedValue(options.result ?? processResult()) + // Why the `in` check and not `??`: an explicit `program: null` is the absent-CLI case. + const resolveCommand = vi + .fn() + .mockResolvedValue('program' in options ? options.program : '/Users/x/.local/bin/agy') + const resolveEnvironment = vi + .fn() + .mockResolvedValue(options.env ?? { PATH: '/Users/x/.local/bin:/usr/bin' }) + return { + runCommand, + resolveCommand, + resolveEnvironment, + fetch: () => + fetchAntigravityRateLimits({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock returns a ProcessResult, which is the whole contract runProcess exposes to this fetcher. + runCommand: runCommand as never, + resolveCommand, + resolveEnvironment, + platform: 'darwin', + now: () => 1_700_000_000_000 + }) + } +} + +describe('fetchAntigravityRateLimits', () => { + beforeEach(() => { + resetAntigravityUsageSupportForTests() + }) + + it('publishes the CLI reading as Antigravity usage', async () => { + const result = await harness({ result: processResult({ stdout: USAGE_ENVELOPE }) }).fetch() + + expect(result.status).toBe('ok') + expect(result.provider).toBe('antigravity') + expect(result.error).toBeNull() + expect(result.weekly).toMatchObject({ usedPercent: 60, windowMinutes: 10_080 }) + expect(result.buckets).toEqual([ + { + name: 'Gemini Models', + usedPercent: 60, + windowMinutes: 10_080, + resetsAt: new Date('2026-10-07T08:08:35Z').getTime(), + resetDescription: null + } + ]) + expect(result.usageMetadata).toMatchObject({ + source: 'cli', + credentialSource: 'antigravity-cli' + }) + }) + + it('never publishes the agy bucket id to the renderer', async () => { + const result = await harness({ result: processResult({ stdout: USAGE_ENVELOPE }) }).fetch() + + for (const bucket of result.buckets ?? []) { + expect(bucket).not.toHaveProperty('id') + } + }) + + it('runs the resolved absolute path with the quota arguments and the login-shell env', async () => { + const h = harness({ result: processResult({ stdout: USAGE_ENVELOPE }) }) + await h.fetch() + + expect(h.runCommand).toHaveBeenCalledTimes(1) + const spec = h.runCommand.mock.calls[0]![0] + expect(spec.program).toBe('/Users/x/.local/bin/agy') + expect(spec.args).toEqual(ANTIGRAVITY_USAGE_ARGS) + // Why the login-shell PATH: agy installs to ~/.local/bin, which Electron's inherited PATH omits. + expect(spec.env).toEqual({ PATH: '/Users/x/.local/bin:/usr/bin' }) + expect(spec.timeoutMs).toBeGreaterThan(0) + expect(h.resolveCommand).toHaveBeenCalledWith('agy', { + platform: 'darwin', + env: { PATH: '/Users/x/.local/bin:/usr/bin' } + }) + }) + + it('reports an absent CLI as unavailable and never spawns', async () => { + const h = harness({ program: null }) + const result = await h.fetch() + + expect(result.status).toBe('unavailable') + expect(result.usageMetadata?.failureKind).toBe('cli-unavailable') + expect(result.error).toContain('was not found on this machine') + expect(h.runCommand).not.toHaveBeenCalled() + }) + + it('reports a signed-out account as unavailable, not as a failed refresh', async () => { + const result = await harness({ + // agy exits 0 and prints this rather than an envelope. + result: processResult({ stderr: 'You are not logged into Antigravity.' }) + }).fetch() + + expect(result.status).toBe('unavailable') + expect(result.usageMetadata?.failureKind).toBe('missing-credentials') + expect(result.error).toContain('Sign in with `agy`') + }) + + it('reports a timeout as its own failure kind', async () => { + const result = await harness({ result: processResult({ timedOut: true }) }).fetch() + + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('usage-unavailable') + expect(result.error).toContain('did not answer in time') + }) + + it('reports an unreadable payload as a parse failure carrying the exit code', async () => { + const result = await harness({ + result: processResult({ code: 2, stdout: 'unknown command /usage' }) + }).fetch() + + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('parse') + expect(result.error).toContain('exit 2') + }) + + it('does not blame the exit code when agy exited cleanly with no payload', async () => { + const result = await harness({ result: processResult({ code: 0, stdout: '' }) }).fetch() + + expect(result.status).toBe('error') + expect(result.error).not.toContain('exit') + }) + + it('reports a spawn failure instead of rejecting the cycle', async () => { + const runCommand = vi.fn().mockRejectedValue(new Error('EACCES')) + const result = await harness({ runCommand }).fetch() + + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('cli-unavailable') + expect(result.error).toContain('EACCES') + }) + + it('never reports quota from a successful read as stale session data', async () => { + const result = await harness({ result: processResult({ stdout: USAGE_ENVELOPE }) }).fetch() + + // Why: this tier meters no 5h pool. The Gemini mirror it replaces filled `session` from a + // 60-minute per-model window and left `weekly` null — exactly backwards (#22511). + expect(result.session).toBeNull() + expect(result.weekly).not.toBeNull() + }) +}) + +/** + * Captured when agy treated `/usage` as a prompt instead of a command: a conversation was started, + * a turn was spent, and the account answered RESOURCE_EXHAUSTED. This is the exact shape the + * unsupported latch has to recognise. + */ +const MODEL_TURN_ENVELOPE = JSON.stringify({ + conversation_id: '28a5ca91-301f-4050-8efc-9c82c4e64df3', + status: 'ERROR', + response: '', + error: 'Individual quota reached. Please upgrade your subscription to increase your limits.', + num_turns: 1 +}) + +describe('agy versions that answer /usage as a prompt', () => { + beforeEach(() => { + resetAntigravityUsageSupportForTests() + }) + + it('reports the quota read as unavailable instead of as a parse failure', async () => { + const result = await harness({ + result: processResult({ stdout: MODEL_TURN_ENVELOPE }) + }).fetch() + + expect(result.status).toBe('unavailable') + expect(result.usageMetadata?.failureKind).toBe('usage-unavailable') + expect(result.error).toContain('answers `/usage` as a prompt') + }) + + it('never spawns agy again once a turn was spent', async () => { + const h = harness({ result: processResult({ stdout: MODEL_TURN_ENVELOPE }) }) + await h.fetch() + expect(h.runCommand).toHaveBeenCalledTimes(1) + + // Why: the evidence costs a turn of the user's quota, so rediscovering it on a 15-minute + // cadence would keep paying for the same answer. + await h.fetch() + await h.fetch() + expect(h.runCommand).toHaveBeenCalledTimes(1) + }) + + it('does not latch when the usage payload parsed, whatever else the envelope says', async () => { + const h = harness({ + result: processResult({ stdout: `${USAGE_ENVELOPE}\n${MODEL_TURN_ENVELOPE}` }) + }) + const first = await h.fetch() + const second = await h.fetch() + + expect(first.status).toBe('ok') + expect(second.status).toBe('ok') + expect(h.runCommand).toHaveBeenCalledTimes(2) + }) + + it('does not latch on an empty or unparsable answer', async () => { + const h = harness({ result: processResult({ code: 2, stdout: 'unknown flag' }) }) + const first = await h.fetch() + const second = await h.fetch() + + // Why: a transient failure is not evidence that the command is unsupported. + expect(first.status).toBe('error') + expect(second.status).toBe('error') + expect(h.runCommand).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/rate-limits/antigravity-usage-fetcher.ts b/src/main/rate-limits/antigravity-usage-fetcher.ts new file mode 100644 index 000000000000..9a1d8faf7f85 --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -0,0 +1,191 @@ +import { runProcess } from '../../shared/child-process/run-process' +import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' +import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' +import type { ProviderRateLimits, UsageRateLimitFailureKind } from '../../shared/rate-limit-types' +import { + ANTIGRAVITY_USAGE_ARGS, + ANTIGRAVITY_USAGE_MAX_OUTPUT_BYTES, + ANTIGRAVITY_USAGE_TIMEOUT_MS, + antigravityCommandName +} from './antigravity-usage-command' +import { parseAntigravityUsageStdout, stdoutShowsModelTurn } from './antigravity-usage-response' + +/** + * Observed verbatim in agy's own log when the keyring holds no session. agy exits 0 and prints this + * instead of a usage envelope, so the text is the only thing that separates "signed out" from + * "answered nothing". + */ +const NOT_SIGNED_IN_MARKER = 'not logged into antigravity' + +const UNSUPPORTED_USAGE_COMMAND_REASON = + 'Antigravity usage is not available. This version of the Antigravity CLI answers `/usage` as a prompt instead of a command, so Orca stopped asking rather than spend quota on it. Update `agy` and restart Orca.' + +/** + * Latched once agy answers the quota read with a model turn. + * + * Why latch instead of retrying: the evidence that this agy cannot answer `/usage` is the same + * event that spends a turn of the user's quota. Retrying on a cadence would keep paying for the + * same discovery, so the probe is abandoned for the rest of the process's life. + */ +let usageCommandUnsupported = false + +/** Clears the unsupported latch. Tests only — a live process has no way back. */ +export function resetAntigravityUsageSupportForTests(): void { + usageCommandUnsupported = false +} + +export type AntigravityUsageDependencies = { + /** Injected so tests exercise the classification without spawning agy. */ + runCommand?: typeof runProcess + resolveCommand?: typeof resolveCommandOnLocalPath + resolveEnvironment?: () => Promise + platform?: NodeJS.Platform + now?: () => number +} + +export type FetchAntigravityRateLimitsOptions = AntigravityUsageDependencies & { + signal?: AbortSignal +} + +function unavailable( + message: string, + failureKind: UsageRateLimitFailureKind, + now: number +): ProviderRateLimits { + return { + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: now, + error: message, + // Why 'unavailable' and not 'error' for every failure: an absent CLI or a signed-out account is + // a state the user can act on, and the status bar renders it as guidance rather than as a + // refresh that keeps failing (#7809, #14227). + status: 'unavailable', + usageMetadata: { source: 'cli', attemptedSources: ['cli'], failureKind } + } +} + +function failed( + message: string, + failureKind: UsageRateLimitFailureKind, + now: number +): ProviderRateLimits { + return { + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: now, + error: message, + status: 'error', + usageMetadata: { source: 'cli', attemptedSources: ['cli'], failureKind } + } +} + +/** + * Reads Antigravity quota from the Antigravity CLI itself. + * + * Why the CLI and not the Gemini mirror it replaces: Orca used to publish a *successful* Gemini + * `retrieveUserQuota` read under the Antigravity provider id. That reported Gemini CLI per-model + * buckets on a 60-minute window, so Antigravity's real pools ("Gemini Models" and "Claude and GPT + * models", each weekly) were never shown and the weekly limit was always null (#9122, #22511). It + * also made the segment depend on an installed `@google/gemini-cli` for token refresh, which an + * Antigravity user has no reason to have. + * + * This runs on whichever machine owns execution; the caller is responsible for not asking a local + * agy about a remote workspace's quota. + */ +export async function fetchAntigravityRateLimits( + options: FetchAntigravityRateLimitsOptions = {} +): Promise { + const now = options.now ?? Date.now + if (usageCommandUnsupported) { + return unavailable(UNSUPPORTED_USAGE_COMMAND_REASON, 'usage-unavailable', now()) + } + const run = options.runCommand ?? runProcess + const resolve = options.resolveCommand ?? resolveCommandOnLocalPath + const platform = options.platform ?? process.platform + const resolveEnvironment = options.resolveEnvironment ?? (() => resolveLoginShellEnvironment()) + + // Why the login shell's env: agy installs to ~/.local/bin, which is on the user's PATH but not on + // the PATH an Electron app inherits from the window server or a desktop launcher. + const env = await resolveEnvironment() + const command = antigravityCommandName() + const program = await resolve(command, { platform, env }) + if (!program) { + return unavailable( + `Antigravity usage is not available. The Antigravity CLI (\`${command}\`) was not found on this machine.`, + 'cli-unavailable', + now() + ) + } + + let result: Awaited> + try { + result = await run({ + program, + args: ANTIGRAVITY_USAGE_ARGS, + env, + timeoutMs: ANTIGRAVITY_USAGE_TIMEOUT_MS, + maxOutputBytes: ANTIGRAVITY_USAGE_MAX_OUTPUT_BYTES, + signal: options.signal + }) + } catch (error) { + return failed( + `Antigravity usage is not available. The Antigravity CLI could not be started: ${error instanceof Error ? error.message : 'unknown error'}.`, + 'cli-unavailable', + now() + ) + } + + if (result.timedOut) { + return failed( + 'Antigravity usage is not available. The Antigravity CLI did not answer in time.', + 'usage-unavailable', + now() + ) + } + + const output = `${result.stdout}\n${result.stderr}` + if (output.toLowerCase().includes(NOT_SIGNED_IN_MARKER)) { + return unavailable( + 'Antigravity usage is not available. Sign in with `agy` to report this account’s quota.', + 'missing-credentials', + now() + ) + } + + const reading = parseAntigravityUsageStdout(result.stdout) + // Why the successful read is checked first: a real reading can never be evidence of a prompt, so + // ordering it ahead of the turn check makes a false latch impossible. + if (!reading && stdoutShowsModelTurn(result.stdout)) { + usageCommandUnsupported = true + return unavailable(UNSUPPORTED_USAGE_COMMAND_REASON, 'usage-unavailable', now()) + } + if (!reading) { + // Why a non-zero exit is reported only here: `runProcess` treats the exit code as data, and agy + // exits 0 for a signed-out read, so the code only adds detail once the payload is missing. + const exitDetail = result.code === 0 || result.code === null ? '' : ` (exit ${result.code})` + return failed( + `Antigravity usage is not available. The Antigravity CLI did not report a quota${exitDetail}.`, + 'parse', + now() + ) + } + + return { + provider: 'antigravity', + session: reading.session, + weekly: reading.weekly, + buckets: reading.buckets.map(({ id: _id, ...bucket }) => bucket), + updatedAt: now(), + error: null, + status: 'ok', + usageMetadata: { + source: 'cli', + attemptedSources: ['cli'], + lastSuccessfulSource: 'cli', + credentialSource: 'antigravity-cli' + } + } +} diff --git a/src/main/rate-limits/antigravity-usage-mirror.test.ts b/src/main/rate-limits/antigravity-usage-mirror.test.ts deleted file mode 100644 index 84c92e1ad22f..000000000000 --- a/src/main/rate-limits/antigravity-usage-mirror.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { ProviderRateLimits, ProviderRateLimitStatus } from '../../shared/rate-limit-types' -import { deriveAntigravityRateLimits } from './antigravity-usage-mirror' - -function geminiSnapshot( - status: ProviderRateLimitStatus, - error: string | null, - usedPercent: number | null = null -): ProviderRateLimits { - return { - provider: 'gemini', - session: - usedPercent === null - ? null - : { usedPercent, windowMinutes: 300, resetsAt: null, resetDescription: null }, - weekly: null, - updatedAt: 1_700_000_000_000, - error, - status - } -} - -describe('deriveAntigravityRateLimits', () => { - it('mirrors a successful Gemini read as shared Code Assist quota', () => { - const antigravity = deriveAntigravityRateLimits(geminiSnapshot('ok', null, 42)) - - expect(antigravity.provider).toBe('antigravity') - expect(antigravity.status).toBe('ok') - expect(antigravity.session?.usedPercent).toBe(42) - expect(antigravity.error).toBeNull() - }) - - it('reports unavailable without quoting the Gemini failure', () => { - const antigravity = deriveAntigravityRateLimits( - geminiSnapshot('error', 'Gemini project ID not found') - ) - - expect(antigravity.provider).toBe('antigravity') - expect(antigravity.status).toBe('unavailable') - expect(antigravity.error).not.toContain('Gemini project ID not found') - expect(antigravity.error).toContain('Antigravity usage is not available') - expect(antigravity.session).toBeNull() - expect(antigravity.weekly).toBeNull() - }) - - it('does not blame a missing sign-in when the quota read itself failed', () => { - const antigravity = deriveAntigravityRateLimits(geminiSnapshot('error', 'Token refresh failed')) - - // Why: the reported symptom is a connected sign-in whose Code Assist read failed. - expect(antigravity.error).toContain('could not be read right now') - expect(antigravity.error).not.toContain('sign-in is connected') - }) - - it('keeps the Gemini timestamp so activation freshness checks are not forced to refetch', () => { - const antigravity = deriveAntigravityRateLimits(geminiSnapshot('error', 'Token refresh failed')) - - expect(antigravity.updatedAt).toBe(1_700_000_000_000) - }) - - it('points at the missing sign-in when the Gemini opt-in is off', () => { - const antigravity = deriveAntigravityRateLimits( - geminiSnapshot('unavailable', 'Gemini CLI OAuth is disabled in settings') - ) - - expect(antigravity.status).toBe('unavailable') - expect(antigravity.error).not.toContain('Gemini CLI OAuth is disabled in settings') - expect(antigravity.error).toContain('Antigravity usage is not available') - expect(antigravity.error).toContain('Gemini CLI sign-in is connected') - }) -}) diff --git a/src/main/rate-limits/antigravity-usage-mirror.ts b/src/main/rate-limits/antigravity-usage-mirror.ts deleted file mode 100644 index 3cea6decc111..000000000000 --- a/src/main/rate-limits/antigravity-usage-mirror.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { ProviderRateLimits } from '../../shared/rate-limit-types' - -// Why: the Antigravity CLI keeps its token in the OS keyring, not in the files the Gemini -// fetcher reads, so Orca never actually queries Antigravity. Only a *successful* Gemini read -// describes shared Google Code Assist quota; republishing a Gemini failure under the -// Antigravity provider id surfaced "Refresh failed" for a request that was never attempted. -const ANTIGRAVITY_NO_SIGN_IN_REASON = - 'Antigravity usage is not available. Orca can only show shared Google Code Assist quota while a Gemini CLI sign-in is connected.' -// Why: a Gemini `error` means the sign-in exists and the quota read failed, so blaming a missing sign-in would misdirect the user. -const ANTIGRAVITY_QUOTA_UNREADABLE_REASON = - 'Antigravity usage is not available. Orca reads it from the shared Google Code Assist quota, which could not be read right now.' - -export function deriveAntigravityRateLimits(gemini: ProviderRateLimits): ProviderRateLimits { - if (gemini.status === 'ok') { - return { ...gemini, provider: 'antigravity' } - } - return { - provider: 'antigravity', - session: null, - weekly: null, - // Why: reuse the Gemini timestamp so activation freshness checks don't force a refetch every cycle. - updatedAt: gemini.updatedAt, - error: - gemini.status === 'unavailable' - ? ANTIGRAVITY_NO_SIGN_IN_REASON - : ANTIGRAVITY_QUOTA_UNREADABLE_REASON, - status: 'unavailable' - } -} diff --git a/src/main/rate-limits/antigravity-usage-real-cli.test.ts b/src/main/rate-limits/antigravity-usage-real-cli.test.ts new file mode 100644 index 000000000000..e4976b2274d2 --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-real-cli.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher' + +/** + * Runs the real Antigravity CLI against the developer's own signed-in account. + * + * Opt in with `ORCA_REAL_AGY_CLI_TEST=1`, the same shape as the real Claude CLI suite. It is off by + * default because it spawns `agy`, needs a live sign-in, and takes seconds — but it is the only + * check that catches agy changing the payload the parser is written against. + * + * `ORCA_REAL_AGY_CLI_TEST=1 pnpm test src/main/rate-limits/antigravity-usage-real-cli.test.ts` + */ +const enabled = process.env.ORCA_REAL_AGY_CLI_TEST === '1' + +describe.skipIf(!enabled)('Antigravity usage against the real agy CLI', () => { + it('reports quota with at least one named pool', async () => { + const result = await fetchAntigravityRateLimits() + + if (result.status !== 'ok') { + // A machine with no agy or no sign-in still proves the classification, not a crash. + expect(result.status).toBe('unavailable') + expect(result.error).toBeTruthy() + return + } + + expect(result.provider).toBe('antigravity') + expect(result.error).toBeNull() + expect(result.buckets?.length).toBeGreaterThan(0) + expect(result.usageMetadata?.source).toBe('cli') + for (const bucket of result.buckets ?? []) { + expect(bucket.name.length).toBeGreaterThan(0) + expect(bucket.usedPercent).toBeGreaterThanOrEqual(0) + expect(bucket.usedPercent).toBeLessThanOrEqual(100) + } + // At least one window must be summarised, or the segment has nothing to draw. + expect(result.session ?? result.weekly).not.toBeNull() + }, 60_000) +}) diff --git a/src/main/rate-limits/antigravity-usage-response.test.ts b/src/main/rate-limits/antigravity-usage-response.test.ts new file mode 100644 index 000000000000..be32522a3194 --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-response.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from 'vitest' +import { + parseAntigravityUsageEnvelope, + parseAntigravityUsageStdout +} from './antigravity-usage-response' + +/** + * Captured verbatim from `agy -p "/usage" --output-format json` on agy 1.2.11 (macOS arm64). + * A tier with no 5h bucket reports weekly alone, which is why `session` is null here. + */ +const REAL_AGY_1_2_11_STDOUT = `{"conversation_id":"","status":"SUCCESS","response":"Gemini Models\\tWeekly Limit Remaining\\t100%\\t2026-10-07T08:08:35Z\\nClaude and GPT models\\tWeekly Limit Remaining\\t100%\\t2026-10-07T08:08:35Z\\n","duration_seconds":0,"num_turns":0,"usage":{"input_tokens":0,"output_tokens":0,"thinking_tokens":0,"cache_read_tokens":0,"total_tokens":0},"command":{"name":"usage","data":{"description":"Within each group, models share a weekly limit.","groups":[{"name":"Gemini Models","description":"Models within this group: Gemini Flash, Gemini Pro","buckets":[{"id":"gemini-weekly","name":"Weekly Limit Remaining","window":"weekly","remaining_fraction":1,"reset_time":"2026-10-07T08:08:35Z"}]},{"name":"Claude and GPT models","description":"Models within this group: Claude Opus, Claude Sonnet, GPT-OSS","buckets":[{"id":"3p-weekly","name":"Weekly Limit Remaining","window":"weekly","remaining_fraction":1,"reset_time":"2026-10-07T08:08:35Z"}]}]}}}` + +function envelope(groups: unknown, description = 'pool help'): unknown { + return { + status: 'SUCCESS', + command: { name: 'usage', data: { description, groups } } + } +} + +describe('parseAntigravityUsageStdout', () => { + it('reads the real agy 1.2.11 payload as two weekly group pools', () => { + const reading = parseAntigravityUsageStdout(REAL_AGY_1_2_11_STDOUT) + + expect(reading).not.toBeNull() + expect(reading?.buckets).toEqual([ + { + id: 'gemini-weekly', + name: 'Gemini Models', + usedPercent: 0, + windowMinutes: 10_080, + resetsAt: new Date('2026-10-07T08:08:35Z').getTime(), + resetDescription: null + }, + { + id: '3p-weekly', + name: 'Claude and GPT models', + usedPercent: 0, + windowMinutes: 10_080, + resetsAt: new Date('2026-10-07T08:08:35Z').getTime(), + resetDescription: null + } + ]) + }) + + it('reports the weekly window the Gemini mirror always left null', () => { + const reading = parseAntigravityUsageStdout(REAL_AGY_1_2_11_STDOUT) + + expect(reading?.weekly).toEqual({ + usedPercent: 0, + windowMinutes: 10_080, + resetsAt: new Date('2026-10-07T08:08:35Z').getTime(), + resetDescription: null + }) + // Why null: this tier meters no 5h pool, and inventing one would claim headroom agy never + // reported. + expect(reading?.session).toBeNull() + }) + + it('ignores log noise printed around the envelope', () => { + const reading = parseAntigravityUsageStdout( + `I0926 16:22:51.157090 quota_manager.go:36] doRefreshQuota\n${REAL_AGY_1_2_11_STDOUT}\nBye.` + ) + + expect(reading?.buckets).toHaveLength(2) + }) + + it('returns null for stdout with no envelope at all', () => { + expect(parseAntigravityUsageStdout('You are not logged into Antigravity.')).toBeNull() + expect(parseAntigravityUsageStdout('')).toBeNull() + expect(parseAntigravityUsageStdout('{ not json')).toBeNull() + }) +}) + +describe('parseAntigravityUsageEnvelope', () => { + it('maps a 5h bucket onto the session window and weekly onto the weekly window', () => { + const reading = parseAntigravityUsageEnvelope( + envelope([ + { + name: 'Gemini Models', + buckets: [ + { + id: 'gemini-5h', + name: '5h Limit Remaining', + window: '5h', + remaining_fraction: 0.25, + reset_time: '2026-09-30T12:00:00Z' + }, + { + id: 'gemini-weekly', + name: 'Weekly Limit Remaining', + window: 'weekly', + remaining_fraction: 0.5, + reset_time: '2026-10-07T00:00:00Z' + } + ] + } + ]) + ) + + expect(reading?.session).toMatchObject({ usedPercent: 75, windowMinutes: 300 }) + expect(reading?.weekly).toMatchObject({ usedPercent: 50, windowMinutes: 10_080 }) + }) + + it('names both windows of one group apart', () => { + const reading = parseAntigravityUsageEnvelope( + envelope([ + { + name: 'Gemini Models', + buckets: [ + { id: 'gemini-5h', name: '5h', window: '5h', remaining_fraction: 1 }, + { id: 'gemini-weekly', name: 'Weekly', window: 'weekly', remaining_fraction: 1 } + ] + } + ]) + ) + + expect(reading?.buckets.map((bucket) => bucket.name)).toEqual([ + 'Gemini Models · 5h', + 'Gemini Models · Weekly' + ]) + }) + + it('drops a disabled bucket instead of drawing it as unused', () => { + const reading = parseAntigravityUsageEnvelope( + envelope([ + { + name: 'Gemini Models', + buckets: [ + // The #22511 account: the 5h pool is not metered and the weekly pool is exhausted. + { id: 'gemini-5h', name: '5h', window: '5h', remaining_fraction: 1, disabled: true }, + { + id: 'gemini-weekly', + name: 'Weekly', + window: 'weekly', + remaining_fraction: 0, + reset_time: '2026-10-01T00:00:00Z' + } + ] + } + ]) + ) + + expect(reading?.buckets).toHaveLength(1) + expect(reading?.buckets[0]).toMatchObject({ id: 'gemini-weekly', usedPercent: 100 }) + expect(reading?.session).toBeNull() + expect(reading?.weekly?.usedPercent).toBe(100) + // Why the single bucket keeps the bare group name: the disabled sibling is not a row. + expect(reading?.buckets[0]?.name).toBe('Gemini Models') + }) + + it('summarises each window by its most constrained group', () => { + const reading = parseAntigravityUsageEnvelope( + envelope([ + { + name: 'Gemini Models', + buckets: [{ id: 'gemini-weekly', window: 'weekly', remaining_fraction: 0.9 }] + }, + { + name: 'Claude and GPT models', + buckets: [{ id: '3p-weekly', window: 'weekly', remaining_fraction: 0.1 }] + } + ]) + ) + + // Why the worst pool: the tier is out of Antigravity when either group is out. + expect(reading?.weekly?.usedPercent).toBe(90) + }) + + it('keeps an unrecognised window as a named bucket without claiming a duration', () => { + const reading = parseAntigravityUsageEnvelope( + envelope([ + { + name: 'Gemini Models', + buckets: [{ id: 'gemini-monthly', window: 'monthly', remaining_fraction: 0.4 }] + } + ]) + ) + + expect(reading?.buckets[0]).toMatchObject({ usedPercent: 60, windowMinutes: 0 }) + expect(reading?.session).toBeNull() + expect(reading?.weekly).toBeNull() + }) + + it('carries agy’s own pool explanation through', () => { + const reading = parseAntigravityUsageEnvelope( + envelope( + [ + { name: 'Gemini Models', buckets: [{ id: 'g', window: 'weekly', remaining_fraction: 1 }] } + ], + 'Quota is consumed proportionally to the cost of the tokens.' + ) + ) + + expect(reading?.description).toBe('Quota is consumed proportionally to the cost of the tokens.') + }) + + it('clamps a fraction outside 0..1', () => { + const reading = parseAntigravityUsageEnvelope( + envelope([ + { + name: 'G', + buckets: [ + { id: 'a', window: 'weekly', remaining_fraction: 1.4 }, + { id: 'b', window: '5h', remaining_fraction: -0.2 } + ] + } + ]) + ) + + expect(reading?.buckets.map((bucket) => bucket.usedPercent)).toEqual([0, 100]) + }) + + it('reads a missing or unparsable reset time as unknown', () => { + const reading = parseAntigravityUsageEnvelope( + envelope([ + { + name: 'G', + buckets: [{ id: 'a', window: 'weekly', remaining_fraction: 1, reset_time: 'soon' }] + } + ]) + ) + + expect(reading?.buckets[0]?.resetsAt).toBeNull() + }) + + it.each([ + ['a non-SUCCESS status', { status: 'ERROR', command: { name: 'usage', data: { groups: [] } } }], + ['another command’s payload', { status: 'SUCCESS', command: { name: 'models', data: {} } }], + ['a missing command', { status: 'SUCCESS' }], + ['no groups', envelope(undefined)], + ['an empty group list', envelope([])], + ['a group with no usable bucket', envelope([{ name: 'G', buckets: [{ id: 'a' }] }])], + [ + 'a group with every bucket disabled', + envelope([ + { + name: 'G', + buckets: [{ id: 'a', window: 'weekly', remaining_fraction: 1, disabled: true }] + } + ]) + ], + ['a non-object', 'nope'], + ['null', null] + ])('returns null for %s', (_label, value) => { + expect(parseAntigravityUsageEnvelope(value)).toBeNull() + }) +}) diff --git a/src/main/rate-limits/antigravity-usage-response.ts b/src/main/rate-limits/antigravity-usage-response.ts new file mode 100644 index 000000000000..3e4077ed35ff --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-response.ts @@ -0,0 +1,253 @@ +import type { RateLimitBucket, RateLimitWindow } from '../../shared/rate-limit-types' +import { deriveMostConstrainedWindow } from './rate-limit-bucket-summary' + +/** + * Parses what `agy -p "/usage" --output-format json` prints. + * + * The shape is agy's print-mode envelope with the slash command's own payload attached, verified + * against agy 1.2.11 on macOS: + * + * ```json + * { "status": "SUCCESS", "response": "Gemini Models\tWeekly Limit Remaining\t100%\t2026-10-07T08:08:35Z\n…", + * "command": { "name": "usage", "data": { "description": "…", "groups": [ + * { "name": "Gemini Models", "description": "Models within this group: Gemini Flash, Gemini Pro", + * "buckets": [{ "id": "gemini-weekly", "name": "Weekly Limit Remaining", "window": "weekly", + * "remaining_fraction": 1, "reset_time": "2026-10-07T08:08:35Z" }] } ] } } } + * ``` + * + * `command.data` is the contract, not the `response` text: the text is a lossy tab-joined rendering + * that rounds the fraction to a whole percent and drops both the bucket ids and `disabled`. + */ + +/** 7 days. agy reports the window by name, so the minute count is Orca's mapping, not agy's. */ +const WEEKLY_WINDOW_MINUTES = 10_080 +/** 5 hours. Only some tiers expose a 5h bucket; a tier without one reports weekly alone. */ +const SESSION_WINDOW_MINUTES = 300 + +export type AntigravityUsageBucket = RateLimitBucket & { + /** agy's stable bucket id (`gemini-weekly`, `gemini-5h`, `3p-weekly`, `3p-5h`). */ + id: string +} + +export type AntigravityUsageReading = { + session: RateLimitWindow | null + weekly: RateLimitWindow | null + buckets: AntigravityUsageBucket[] + /** agy's own explanation of how the pools work, shown as the segment's help text. */ + description: string | null +} + +type RawBucket = { + id?: unknown + name?: unknown + window?: unknown + remaining_fraction?: unknown + reset_time?: unknown + disabled?: unknown +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function readString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +/** + * Maps agy's window name onto Orca's minute count. + * + * Why only these two: agy groups models into pools that share a limit, and a pool carries at most a + * rolling 5h bucket and a weekly bucket. An unrecognised name is reported as a named bucket with no + * window rather than being forced into one of the two, so a new agy window cannot silently be drawn + * as a weekly limit. + */ +function windowMinutesFor(window: string | null): number | null { + if (window === 'weekly') { + return WEEKLY_WINDOW_MINUTES + } + if (window === '5h') { + return SESSION_WINDOW_MINUTES + } + return null +} + +function parseResetsAt(value: unknown): number | null { + const text = readString(value) + if (!text) { + return null + } + const parsed = new Date(text).getTime() + return Number.isFinite(parsed) ? parsed : null +} + +/** + * Why a group name and not the bucket name: every bucket in the payload is called "Weekly Limit + * Remaining", so the bucket name alone renders two identical rows. The group is what distinguishes + * them ("Gemini Models" vs "Claude and GPT models"), and the agy label is only appended when one + * group reports more than one window. + */ +function formatBucketName(groupName: string, bucketName: string | null, siblings: number): string { + if (siblings <= 1 || !bucketName) { + return groupName + } + return `${groupName} · ${bucketName}` +} + +function parseBucket( + raw: RawBucket, + groupName: string, + siblings: number +): AntigravityUsageBucket | null { + const id = readString(raw.id) + const fraction = raw.remaining_fraction + if (!id || typeof fraction !== 'number' || !Number.isFinite(fraction)) { + return null + } + // Why skip: a disabled bucket is one the tier does not meter at all. #22511 saw `gemini-5h` + // disabled while `gemini-weekly` was exhausted; drawing the disabled bucket as 0% used would + // report headroom the account does not have. + if (raw.disabled === true) { + return null + } + const usedPercent = Math.min(100, Math.max(0, Math.round((1 - fraction) * 100))) + return { + id, + name: formatBucketName(groupName, readString(raw.name), siblings), + usedPercent, + // Why 0 and not null: RateLimitWindow requires a number, and an unrecognised agy window still + // carries a real remaining fraction worth showing as a named bucket. + windowMinutes: windowMinutesFor(readString(raw.window)) ?? 0, + resetsAt: parseResetsAt(raw.reset_time), + resetDescription: null + } +} + +function parseGroups(groups: unknown): AntigravityUsageBucket[] { + if (!Array.isArray(groups)) { + return [] + } + const parsed: AntigravityUsageBucket[] = [] + for (const group of groups) { + if (!isRecord(group)) { + continue + } + const groupName = readString(group.name) + const buckets = Array.isArray(group.buckets) ? group.buckets : [] + if (!groupName) { + continue + } + const enabled = buckets.filter( + (bucket): bucket is RawBucket => isRecord(bucket) && bucket.disabled !== true + ) + for (const bucket of enabled) { + const result = parseBucket(bucket, groupName, enabled.length) + if (result) { + parsed.push(result) + } + } + } + return parsed +} + +/** + * Reads the usage payload out of an agy print-mode envelope. + * + * Returns null when the envelope is not a successful usage reply, which the caller reports as an + * unreadable quota rather than as an empty one — "no buckets" and "agy did not answer" are + * different states and only the first is safe to draw as 0% used. + */ +export function parseAntigravityUsageEnvelope(value: unknown): AntigravityUsageReading | null { + if (!isRecord(value)) { + return null + } + if (readString(value.status) !== 'SUCCESS') { + return null + } + const command = value.command + if (!isRecord(command)) { + return null + } + // Why check the command name: `/usage` and `/quota` are aliases that both answer as `usage`, so + // the name is what proves the payload is a quota reply and not some other command's data. + if (readString(command.name) !== 'usage') { + return null + } + const data = command.data + if (!isRecord(data)) { + return null + } + const buckets = parseGroups(data.groups) + if (buckets.length === 0) { + return null + } + // Why drop the id first: the summary is a RateLimitWindow, and the summariser only strips `name`, + // so an id left on the bucket would ride into the published window. + const windowsOf = (minutes: number): RateLimitBucket[] => + buckets + .filter((bucket) => bucket.windowMinutes === minutes) + .map(({ id: _id, ...bucket }) => bucket) + return { + session: deriveMostConstrainedWindow(windowsOf(SESSION_WINDOW_MINUTES)), + weekly: deriveMostConstrainedWindow(windowsOf(WEEKLY_WINDOW_MINUTES)), + buckets, + description: readString(data.description) + } +} + +/** Finds the usage envelope in agy's stdout, which may carry log noise around the JSON line. */ +export function parseAntigravityUsageStdout(stdout: string): AntigravityUsageReading | null { + for (const line of stdout.split('\n')) { + const trimmed = line.trim() + if (!trimmed.startsWith('{')) { + continue + } + try { + const reading = parseAntigravityUsageEnvelope(JSON.parse(trimmed)) + if (reading) { + return reading + } + } catch { + continue + } + } + return null +} + +/** + * True when the envelope shows agy ran a model turn instead of answering a command. + * + * Why this matters: in print mode an *unrecognised* slash command is not an error — agy sends the + * text to the model as an ordinary prompt. On a build of agy that does not know `/usage`, polling + * would quietly start a conversation and spend the user's quota every cycle while Orca reported + * "did not report a quota". A real command reply carries an empty `conversation_id` and + * `num_turns: 0`; a prompt carries a conversation id and at least one turn. + */ +export function didRunModelTurn(value: unknown): boolean { + if (!isRecord(value)) { + return false + } + const turns = value.num_turns + if (typeof turns === 'number' && turns > 0) { + return true + } + return readString(value.conversation_id) !== null +} + +/** Scans agy stdout for evidence that the quota read was answered by the model, not by a command. */ +export function stdoutShowsModelTurn(stdout: string): boolean { + for (const line of stdout.split('\n')) { + const trimmed = line.trim() + if (!trimmed.startsWith('{')) { + continue + } + try { + if (didRunModelTurn(JSON.parse(trimmed))) { + return true + } + } catch { + continue + } + } + return false +} diff --git a/src/main/rate-limits/gemini-bucket-formatting.ts b/src/main/rate-limits/gemini-bucket-formatting.ts index 3d58db808b73..fdee74e93234 100644 --- a/src/main/rate-limits/gemini-bucket-formatting.ts +++ b/src/main/rate-limits/gemini-bucket-formatting.ts @@ -1,4 +1,5 @@ import type { RateLimitBucket, RateLimitWindow } from '../../shared/rate-limit-types' +import { deriveMostConstrainedWindow } from './rate-limit-bucket-summary' const MODEL_ID_TO_BUCKET_NAME: Record = { 'gemini-3.1-pro': '3.1 Pro', @@ -73,12 +74,5 @@ export function deduplicateBuckets( } export function deriveSessionSummary(buckets: RateLimitBucket[]): RateLimitWindow | null { - if (buckets.length === 0) { - return null - } - const mostConstrained = buckets.reduce((worst, bucket) => { - return bucket.usedPercent > worst.usedPercent ? bucket : worst - }) - const { name: _name, ...window } = mostConstrained - return window + return deriveMostConstrainedWindow(buckets) } diff --git a/src/main/rate-limits/rate-limit-bucket-summary.ts b/src/main/rate-limits/rate-limit-bucket-summary.ts new file mode 100644 index 000000000000..585c24a95aa5 --- /dev/null +++ b/src/main/rate-limits/rate-limit-bucket-summary.ts @@ -0,0 +1,19 @@ +import type { RateLimitBucket, RateLimitWindow } from '../../shared/rate-limit-types' + +/** + * Collapses named buckets into the one window a user is actually limited by. + * + * The most-consumed bucket is the binding constraint: a provider that reports one pool per model + * family runs out of the whole tier when any single pool does, so the summary has to follow the + * worst pool rather than an average. + */ +export function deriveMostConstrainedWindow(buckets: RateLimitBucket[]): RateLimitWindow | null { + if (buckets.length === 0) { + return null + } + const mostConstrained = buckets.reduce((worst, bucket) => + bucket.usedPercent > worst.usedPercent ? bucket : worst + ) + const { name: _name, ...window } = mostConstrained + return window +} diff --git a/src/main/rate-limits/rate-limit-service-test-harness.ts b/src/main/rate-limits/rate-limit-service-test-harness.ts index f51839d108b9..caf5ed780a54 100644 --- a/src/main/rate-limits/rate-limit-service-test-harness.ts +++ b/src/main/rate-limits/rate-limit-service-test-harness.ts @@ -12,6 +12,7 @@ import { fetchCursorRateLimits } from './cursor-fetcher' import { readCursorAuthSession } from './cursor-auth' import { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection' import { fetchZcodeRateLimits } from './zcode-usage-fetcher' +import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher' import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' export type Deferred = { @@ -94,6 +95,9 @@ export function mockFreshBackgroundProviderFetches(): void { vi.mocked(fetchGrokRateLimits).mockImplementation(async () => unavailableProvider('grok')) vi.mocked(fetchCursorRateLimits).mockImplementation(async () => unavailableProvider('cursor')) vi.mocked(fetchZcodeRateLimits).mockImplementation(async () => unavailableProvider('zcode')) + vi.mocked(fetchAntigravityRateLimits).mockImplementation(async () => + unavailableProvider('antigravity') + ) } /** Shared `beforeEach` body: healthy stubs for every provider the service polls. */ @@ -113,6 +117,7 @@ export function resetRateLimitProviderMocks(): void { }) vi.mocked(fetchCursorRateLimits).mockResolvedValue(unavailableProvider('cursor')) vi.mocked(fetchZcodeRateLimits).mockResolvedValue(unavailableProvider('zcode')) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(unavailableProvider('antigravity')) vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false) vi.mocked(readGrokAuthSession).mockReturnValue({ status: 'missing' }) vi.mocked(readCursorAuthSession).mockResolvedValue({ status: 'missing' }) diff --git a/src/main/rate-limits/service-account-target-selection.test.ts b/src/main/rate-limits/service-account-target-selection.test.ts index bb5de4b64856..27e93208c1a1 100644 --- a/src/main/rate-limits/service-account-target-selection.test.ts +++ b/src/main/rate-limits/service-account-target-selection.test.ts @@ -36,6 +36,10 @@ vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-antigravity-usage.test.ts b/src/main/rate-limits/service-antigravity-usage.test.ts index 3696f434dec5..1ffe7cabc34d 100644 --- a/src/main/rate-limits/service-antigravity-usage.test.ts +++ b/src/main/rate-limits/service-antigravity-usage.test.ts @@ -3,6 +3,7 @@ import { RateLimitService } from './service' import { fetchClaudeRateLimits } from './claude-fetcher' import { fetchCodexRateLimits } from './codex-fetcher' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' +import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher' import { errorProvider, okProvider, @@ -41,6 +42,10 @@ vi.mock('./grok-fetcher', () => ({ vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./cursor-fetcher', () => ({ fetchCursorRateLimits: vi.fn() })) @@ -62,49 +67,69 @@ describe('RateLimitService Antigravity usage', () => { resetRateLimitProviderMocks() vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7)) vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20)) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(okProvider('antigravity', 30)) + }) + + it('publishes the Antigravity CLI reading, not the Gemini one', async () => { + vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 42, Date.now())) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(okProvider('antigravity', 30)) + const service = new RateLimitService() + + await service.refresh() + + const state = service.getState() + expect(state.antigravity?.status).toBe('ok') + expect(state.antigravity?.provider).toBe('antigravity') + // Why both: the mirror made these two numbers the same value by construction. + expect(state.antigravity?.session?.usedPercent).toBe(30) + expect(state.gemini?.session?.usedPercent).toBe(42) }) - it('does not republish a Gemini failure as an Antigravity refresh failure', async () => { + it('keeps an Antigravity reading through a Gemini failure', async () => { vi.mocked(fetchGeminiRateLimits).mockResolvedValue( errorProvider('gemini', 'Gemini project ID not found') ) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(okProvider('antigravity', 55)) const service = new RateLimitService() await service.refresh() const state = service.getState() - expect(state.antigravity?.status).toBe('unavailable') - expect(state.antigravity?.error).not.toContain('Gemini project ID not found') - expect(state.antigravity?.session).toBeNull() - // Why: the real Gemini failure must still surface under its own provider. + // Why: the two providers no longer share a credential or an endpoint, so a Gemini + // token problem is not evidence about Antigravity quota (#9122). + expect(state.antigravity?.status).toBe('ok') + expect(state.antigravity?.session?.usedPercent).toBe(55) expect(state.gemini?.status).toBe('error') expect(state.gemini?.error).toBe('Gemini project ID not found') }) - it('keeps mirroring a successful Gemini read under the Antigravity provider', async () => { + it('reports an Antigravity failure without touching Gemini', async () => { vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 42, Date.now())) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue( + errorProvider('antigravity', 'The Antigravity CLI did not report a quota.') + ) const service = new RateLimitService() await service.refresh() const state = service.getState() - expect(state.antigravity?.status).toBe('ok') - expect(state.antigravity?.provider).toBe('antigravity') - expect(state.antigravity?.session?.usedPercent).toBe(42) + expect(state.antigravity?.status).toBe('error') + expect(state.antigravity?.session).toBeNull() + expect(state.gemini?.status).toBe('ok') }) - it('never leaves a cached Antigravity snapshot in the error retry lane', async () => { - vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 42, Date.now())) + it('surfaces a rejected Antigravity fetch as that provider\u2019s error', async () => { + vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 42, Date.now())) + vi.mocked(fetchAntigravityRateLimits).mockRejectedValue(new Error('spawn agy ENOENT')) const service = new RateLimitService() - await service.refresh() - vi.mocked(fetchGeminiRateLimits).mockResolvedValue( - errorProvider('gemini', 'Token refresh failed') - ) await service.refresh() - // Why: stale-retention would otherwise show Gemini numbers as "Refresh failed" Antigravity usage. - expect(service.getState().antigravity?.status).toBe('unavailable') - expect(service.getState().antigravity?.session).toBeNull() + const state = service.getState() + expect(state.antigravity?.status).toBe('error') + expect(state.antigravity?.error).toContain('spawn agy ENOENT') + // Why: a thrown Antigravity fetch must not abort the cycle for everyone else. + expect(state.claude?.status).toBe('ok') + expect(state.gemini?.status).toBe('ok') }) }) diff --git a/src/main/rate-limits/service-cursor-usage.test.ts b/src/main/rate-limits/service-cursor-usage.test.ts index 3646f9f85863..03f5c1f4a168 100644 --- a/src/main/rate-limits/service-cursor-usage.test.ts +++ b/src/main/rate-limits/service-cursor-usage.test.ts @@ -25,6 +25,10 @@ vi.mock('./grok-fetcher', () => ({ fetchGrokRateLimits: vi.fn() })) vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./cursor-fetcher', () => ({ fetchCursorRateLimits: vi.fn() })) vi.mock('./cursor-auth', () => ({ readCursorAuthSession: vi.fn() })) vi.mock('../minimax/minimax-cookie-store', () => ({ hasMiniMaxSessionCookie: vi.fn(() => false) })) diff --git a/src/main/rate-limits/service-inactive-account-previews.test.ts b/src/main/rate-limits/service-inactive-account-previews.test.ts index 7db0a60d6617..cae71c569e71 100644 --- a/src/main/rate-limits/service-inactive-account-previews.test.ts +++ b/src/main/rate-limits/service-inactive-account-previews.test.ts @@ -43,6 +43,10 @@ vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-live-claude-usage.test.ts b/src/main/rate-limits/service-live-claude-usage.test.ts index 07f6ccf0d272..8c35ab77acc7 100644 --- a/src/main/rate-limits/service-live-claude-usage.test.ts +++ b/src/main/rate-limits/service-live-claude-usage.test.ts @@ -40,6 +40,10 @@ vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-minimax-usage.test.ts b/src/main/rate-limits/service-minimax-usage.test.ts index 93f95de78474..8f31e9a6da7a 100644 --- a/src/main/rate-limits/service-minimax-usage.test.ts +++ b/src/main/rate-limits/service-minimax-usage.test.ts @@ -37,6 +37,10 @@ vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts index 9577a87d6a3f..e0a1d9c9b042 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -45,6 +45,10 @@ vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-window-activation.test.ts b/src/main/rate-limits/service-window-activation.test.ts index 54296c396b9b..8cbb25915037 100644 --- a/src/main/rate-limits/service-window-activation.test.ts +++ b/src/main/rate-limits/service-window-activation.test.ts @@ -45,6 +45,10 @@ vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service/service-full-cycle-application.ts b/src/main/rate-limits/service/service-full-cycle-application.ts index 70b58933c163..b6a9b40373ee 100644 --- a/src/main/rate-limits/service/service-full-cycle-application.ts +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -1,5 +1,4 @@ import { RateLimitServiceFullCyclePreparation } from './service-full-cycle-preparation' -import { deriveAntigravityRateLimits } from '../antigravity-usage-mirror' import { settleSiblingProviderResult } from './service-sibling-provider-result' import type { ProviderRateLimits } from './service-types' @@ -37,7 +36,8 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ ], grokResultPromise, cursorResultPromise, - zcodeResultPromise + zcodeResultPromise, + antigravityResultPromise } = prepared if (signal.aborted) { return @@ -82,9 +82,6 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ status: 'error' } satisfies ProviderRateLimits) - // Why: Antigravity can only borrow a *successful* Gemini read; a Gemini failure is not an Antigravity failure. - const antigravity = deriveAntigravityRateLimits(gemini) - const opencodeGo = opencodeGoResult.status === 'fulfilled' ? opencodeGoResult.value @@ -159,7 +156,6 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ this.trackActiveFailureStreak('codex', codex) } this.trackActiveFailureStreak('gemini', gemini) - this.trackActiveFailureStreak('antigravity', antigravity) if (shouldApplyOpencode) { this.trackActiveFailureStreak('opencode-go', opencodeGo) } @@ -186,7 +182,6 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ : this.applyStalePolicy(opencodeGo, previousState.opencodeGo) : this.state.opencodeGo, kimi: this.applyStalePolicy(kimi, previousState.kimi), - antigravity: this.applyStalePolicy(antigravity, previousState.antigravity), minimax: shouldApplyMiniMax ? miniMaxConfigChanged ? miniMax @@ -194,10 +189,11 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ : this.state.minimax }) - const [grokSettled, cursorSettled, zcodeSettled] = await Promise.all([ + const [grokSettled, cursorSettled, zcodeSettled, antigravitySettled] = await Promise.all([ grokResultPromise, cursorResultPromise, - zcodeResultPromise + zcodeResultPromise, + antigravityResultPromise ]) if (signal.aborted) { return @@ -205,6 +201,7 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ const grok = settleSiblingProviderResult('grok', grokSettled) const cursor = settleSiblingProviderResult('cursor', cursorSettled) const zcode = settleSiblingProviderResult('zcode', zcodeSettled) + const antigravity = settleSiblingProviderResult('antigravity', antigravitySettled) // Why: the stale policy keeps a recent snapshot through a failed refresh, but // a snapshot belonging to a different Cursor account must not survive the // switch — the Accounts pane would name the new account beside the old @@ -225,6 +222,7 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ this.trackActiveFailureStreak('grok', grok) this.trackActiveFailureStreak('cursor', cursor) this.trackActiveFailureStreak('zcode', zcode) + this.trackActiveFailureStreak('antigravity', antigravity) this.updateState({ ...this.state, grok: this.applyStalePolicy(grok, previousState.grok), @@ -232,7 +230,8 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ zcode: zcode.status === 'error' && !sameZcodeAccount ? zcode - : this.applyStalePolicy(zcode, previousState.zcode) + : this.applyStalePolicy(zcode, previousState.zcode), + antigravity: this.applyStalePolicy(antigravity, previousState.antigravity) }) } } diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts index 96faa82eb345..5395ace5fbff 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -6,6 +6,7 @@ import { readGrokAuthSession } from '../grok-auth' import { fetchCursorRateLimits } from '../cursor-fetcher' import { readCursorAuthSession } from '../cursor-auth' import { fetchZcodeRateLimits } from '../zcode-usage-fetcher' +import { fetchAntigravityRateLimits } from '../antigravity-usage-fetcher' import { fetchMiniMaxRateLimits } from '../minimax/minimax-fetcher' import { createHash } from 'node:crypto' import { fetchOpenCodeGoUsage } from '../opencode-go-usage-source-selection' @@ -46,6 +47,7 @@ export type FetchAllCyclePrepared = { grokResultPromise: Promise cursorResultPromise: Promise zcodeResultPromise: Promise + antigravityResultPromise: Promise } export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServiceFetchPolicy { @@ -153,6 +155,14 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ (reason) => ({ status: 'rejected', reason }) as const ) + // Why its own promise: the Antigravity read spawns `agy` and waits ~2.5 s for the CLI to start + // its language server and refresh the quota. Inside the awaited tuple that latency would be + // added to every other provider's cycle. + const antigravityResultPromise = fetchAntigravityRateLimits({ signal }).then( + (value) => ({ status: 'fulfilled', value }) as const, + (reason) => ({ status: 'rejected', reason }) as const + ) + const missingWslCodexHome = codexFetchGated || codexHomePath ? null : this.getMissingWslCodexHomeResult(codexTarget) const grokResultPromise = fetchGrokRateLimits({ @@ -239,7 +249,8 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ ], grokResultPromise, cursorResultPromise, - zcodeResultPromise + zcodeResultPromise, + antigravityResultPromise } } } diff --git a/src/main/repo-git-remote-identity-enrichment.test.ts b/src/main/repo-git-remote-identity-enrichment.test.ts index d1e54e11de8a..54350dda3d4f 100644 --- a/src/main/repo-git-remote-identity-enrichment.test.ts +++ b/src/main/repo-git-remote-identity-enrichment.test.ts @@ -614,22 +614,4 @@ describe('retiring probes for removed repos', () => { expect(listHandlersChanged).toHaveBeenCalledTimes(1) expect(runtimeChanged).toHaveBeenCalledTimes(1) }) - - it('still notifies a caller whose sweep was coalesced into one already running', async () => { - const first = deferred() - vi.mocked(probeGitRemoteIdentity).mockReturnValue(first.promise) - const store = makeMutableStore([makeRepo()]) - const listHandlerChanged = vi.fn() - // The runtime RPC caller also drops a resolved-worktree cache, so it must not be dropped. - const runtimeChanged = vi.fn() - - enrichMissingRepoGitRemoteIdentities(store, { onChanged: listHandlerChanged }) - enrichMissingRepoGitRemoteIdentities(store, { onChanged: runtimeChanged }) - - first.resolve(resolvedProbe) - await drainEnrichmentSweep() - - expect(listHandlerChanged).toHaveBeenCalledTimes(1) - expect(runtimeChanged).toHaveBeenCalledTimes(1) - }) }) diff --git a/src/main/repo-maintenance-idle-gate.ts b/src/main/repo-maintenance-idle-gate.ts index 78bb25fe68c9..dec9acb549fb 100644 --- a/src/main/repo-maintenance-idle-gate.ts +++ b/src/main/repo-maintenance-idle-gate.ts @@ -6,6 +6,7 @@ import { } from './git/local-repo-ref-maintenance' import { hasWorktreeRemovalsInFlight } from './ipc/worktrees/worktree-ipc-context' import { hasPendingWorktreeCreatePreparations } from './worktree-create-preparation' +import { hasPendingWorktreeRemovals } from './worktree-background-removal' /** * The app-wide "not now" answer for idle repo maintenance. @@ -32,6 +33,7 @@ export function installRepoMaintenanceIdleGate( inputs.getWorkingAgentCount() > 0 || hasPendingWorktreeCreatePreparations() || hasWorktreeRemovalsInFlight() || + hasPendingWorktreeRemovals() || isOnBatteryPower() ) // Do-not-start, never stop-what-is-running. Killing a pack to honour a battery diff --git a/src/main/runtime/__fixtures__/antigravity-1-2-14-busy-streaming.meta.json b/src/main/runtime/__fixtures__/antigravity-1-2-14-busy-streaming.meta.json new file mode 100644 index 000000000000..f6f0703b11a0 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-1-2-14-busy-streaming.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T19:11:25.475Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy 1.2.14, macOS, Google account, AGY_CLI_HIDE_ACCOUNT_INFO=1, one turn, recording stopped while the answer was streaming. Truncated 111495 -> 11142 bytes, after the first frame with the answer streaming under the spinner.", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-1-2-14-busy-streaming.txt b/src/main/runtime/__fixtures__/antigravity-1-2-14-busy-streaming.txt new file mode 100644 index 000000000000..92128e72c7d4 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-1-2-14-busy-streaming.txt @@ -0,0 +1,166 @@ +_Ga=q,f=32,s=1,v=1,i=31;AAAAAA==\[?2026$p[?2027$p[>4m[?1049h[?25l[?5W[?2004h[>4;2m[>1u[?u +▄▀▀▄ +▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀ + ▄▀▀ ▀▀▄ + ▄▀▀ ▀▀▄ + + Welcome back! You are currently not signed in. + + ⣷ Signing in...[>4m[<1u[?1049l[>4;2m[>1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.14 +▀▀▀▀▀▀ ~/orca/workspaces/orca/fix-wait-detector-scrollback +▀▀▀▀▀▀▀▀ +▄▀▀ ▀▀▄ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcuts [?25h[?25l Gemini 3.8 Flash (High) +~/orca/workspaces/orca/fix-wait-detector-scrollback + + + + + + +Gemini 3.8 Flash · high [?25h[?25lWitho + + G[?25h[?25lut using an[?25h[?25ly tools, write[?25h[?25lthe numbers 1 t[?25h[?25lo 600 in chat, on[?25h[?25le per line.[?25h[?25l + +? for shortcuts[?25h[?25lM +> Without using any tools, write the numbers 1 to 600 in chat, one per line. +⣷ Generating... +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +esc to cancelGemini 3.8 Flash · high [?25h[?25l.. + +[?25h[?25l ⣯ Generating... + +[?25h[?25l ⣟ Ge + +[?25h[?25l ⡿ Generat + +[?25h[?25l ⢿ Generating.. + +[?25h[?25l ⣻ Generating.. + +[?25h[?25l ⣽ + +[?25h[?25l ⣾ Gene + +[?25h[?25l ⣷ Generatin + +[?25h[?25l ⣯ Generating... + +[?25h[?25lg... + +[?25h[?25l ⣟ + +[?25h[?25l ⡿ + +[?25h[?25l ⢿ Gene + +[?25h[?25l ⣻ Generatin + +[?25h[?25l ⣽ Generating... + +[?25h[?25l ⣾ Generating... + +[?25h[?25l ⣷ G + +[?25h[?25l ⣯ Genera + +[?25h[?25l ⣟ Generating. + +[?25h[?25l ⡿ Generating... + +[?25h[?25l ⢿ Generating... + +[?25h[?25l ⣻ Ge + +[?25h[?25l ⣽ Generat + +[?25h[?25l ⣾ Generating.. + +[?25h[?25l ⣷ Generating... + +[?25h[?25l ⣯ Generating... + +[?25h[?25l ⣟ Gen + +[?25h[?25l ⡿ Generatin + +[?25h[?25l ⢿ Generating... + +[?25h[?25l ⣻ Generating... + +[?25h[?25l ⣽ + +[?25h[?25l ⣾ Gene + +[?25h[?25l I'm currently working through the process of generating the numbers one by one, ensuring each is presented on its ... + +[?25h[?25lrocess  + +[?25h[?25l ⣷ I'm currently working through the process of gen + +[?25h[?25l ⣯ I'm currently working through the process of generating th + +[?25h[?25l ⣟ I'm currently working through the process of generating the numbers  + +[?25h[?25l ⡿ I'm currently working through the process of generating the numbers one by one + +[?25h[?25l ⢿ I'm currently working through the process of generating the numbers one by one, ensuring + +[?25h[?25l ⣻ I'm currently working through the process of generating the numbers one by one, ensuring each is p + +[?25h[?25l ⣽ I'm currently working through the process of generating the numbers one by one, ensuring each is presented o + +[?25h[?25l ⣾ I'm currently working through the process of generating the numbers one by one, ensuring each is presented on its ... + +[?25h[?25l ⣷ I'm currently working through the process of generating the numbers one by one, ensuring each is presented on its .. + +[?25h[?25l. + +[?25h[?25l ⣯ I'm currently working through the process of generating the numbers one by one, ensuring each is presented on its ... + +[?25h[?25l  +▸ Thought for 4s + I'm currently working through the process of generating the numbers one by one, ensuring each is presented on its... + 1 + 2 + 3 + 4 + 5 + 6 +⣯ I'm currently working through the process of generating the numbers one by one, ensuring each is presented on its ... +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +esc to cancelGemini 3.8 Flash · high [?25h[?25l  7 + 8 + 9 +⣯ I'm currently working through the process of generating the numbers one by one, ensuring each is presented on its ... +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +esc to cancelGemini 3.8 Flash · high [?25h[?25l ⣟ I'm currently working through the process of generating the numbers one by one, ensuring each is presen + +[?25h[?25l ⡿ I'm currently working through the process of generating the numbers one by one, ensuring each is pres + +[?25h[?25l  10 + 11 + 12 + 13 + 14 + 15 + 16 + 17 +⡿ I'm currently working through the process of generating the numbers one by one, ensuring each is presented on its ... +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +esc to cancelGemini 3.8 Flash · high [?25h \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/antigravity-1-2-14-busy-thinking.meta.json b/src/main/runtime/__fixtures__/antigravity-1-2-14-busy-thinking.meta.json new file mode 100644 index 000000000000..a2e120d3d873 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-1-2-14-busy-thinking.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T19:10:17.495Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy 1.2.14, macOS, Google account, AGY_CLI_HIDE_ACCOUNT_INFO=1, one turn, recording stopped while the turn was still running", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-1-2-14-busy-thinking.txt b/src/main/runtime/__fixtures__/antigravity-1-2-14-busy-thinking.txt new file mode 100644 index 000000000000..5bdaba552f78 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-1-2-14-busy-thinking.txt @@ -0,0 +1,98 @@ +_Ga=q,f=32,s=1,v=1,i=31;AAAAAA==\[?2026$p[?2027$p[>4m[?1049h[?25l[?5W[?2004h[>4;2m[>1u[?u +▄▀▀▄ +▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀ + ▄▀▀ ▀▀▄ + ▄▀▀ ▀▀▄ + + Welcome back! You are currently not signed in. + + ⣷ Signing in...[>4m[<1u[?1049l[>4;2m[>1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.14 +▀▀▀▀▀▀ ~/orca/workspaces/orca/fix-wait-detector-scrollback +▀▀▀▀▀▀▀▀ +▄▀▀ ▀▀▄ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcuts [?25h[?25l Gemini 3.8 Flash (High) +~/orca/workspaces/orca/fix-wait-detector-scrollback + + + + + + +Gemini 3.8 Flash · high [?25h[?25lWithout + + G[?25h[?25l using any to[?25h[?25lols, write the[?25h[?25l numbers 1[?25h[?25lto 400 in chat[?25h[?25l, one per line.[?25h[?25l + +? for shortcuts[?25h[?25lM +> Without using any tools, write the numbers 1 to 400 in chat, one per line. +⣷ Generating... +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +esc to cancelGemini 3.8 Flash · high [?25h[?25l.. + +[?25h[?25l ⣯ Generating... + +[?25h[?25l ⣟ Gen + +[?25h[?25l ⡿ Generati + +[?25h[?25l ⢿ Generating... + +[?25h[?25l ⣻ Generating... + +[?25h[?25l ⣽ + +[?25h[?25l ⣾ Gene + +[?25h[?25l ⣷ Generatin + +[?25h[?25l ⣯ Generating... + +[?25h[?25l ⣟ Generating... + +[?25h[?25l ⡿ + +[?25h[?25l ⢿ Gener + +[?25h[?25l ⣻ Generating + +[?25h[?25l ⣽ Generating... + +[?25h[?25l ⣾ Generating... + +[?25h[?25l ⣷ G + +[?25h[?25l ⣯ Genera + +[?25h[?25l ⣟ Generating. + +[?25h[?25l ⡿ Generating... + +[?25h[?25l ⢿ Generating... + +[?25h[?25l ⣻ G + +[?25h[?25l ⣽ Generat + +[?25h[?25l ⣾ Generating.. + +[?25h[?25l ⣷ Generating... + +[?25h[?25l ⣯ Generating... + +[?25h[?25l ⣟ Gen + +[?25h[?25l ⡿ Generati + +[?25h[?25l ⢿ Generating... + +[?25h[?25l ⣻ Generating... + +[?25h \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/antigravity-1-2-14-command-palette.meta.json b/src/main/runtime/__fixtures__/antigravity-1-2-14-command-palette.meta.json new file mode 100644 index 000000000000..b03b1ae59c2d --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-1-2-14-command-palette.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T19:09:30.583Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy 1.2.14, macOS, Google account, AGY_CLI_HIDE_ACCOUNT_INFO=1, slash-command palette left open", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-1-2-14-command-palette.txt b/src/main/runtime/__fixtures__/antigravity-1-2-14-command-palette.txt new file mode 100644 index 000000000000..94d351d8d598 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-1-2-14-command-palette.txt @@ -0,0 +1,48 @@ +_Ga=q,f=32,s=1,v=1,i=31;AAAAAA==\[?2026$p[?2027$p[>4m[?1049h[?25l[?5W[?2004h[>4;2m[>1u[?u +▄▀▀▄ +▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀ + ▄▀▀ ▀▀▄ + ▄▀▀ ▀▀▄ + + Welcome back! You are currently not signed in. + + ⣷ Signing in...[>4m[<1u[?1049l[>4;2m[>1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.14 +▀▀▀▀▀▀ ~/orca/workspaces/orca/fix-wait-detector-scrollback +▀▀▀▀▀▀▀▀ +▄▀▀ ▀▀▄ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcuts [?25h[?25l Gemini 3.8 Flash (High) +~/orca/workspaces/orca/fix-wait-detector-scrollback + + + + + + +Gemini 3.8 Flash · high [?25h[?25l/ + +> /add-dir Add a directory to the workspace + /agentsList available custom agents + /artifactView and review artifacts + /btwAsk a side question without interrupting the current task + /changelogShow release notes and changes + ↓ 95 more + + ↑/↓ Navigate · enter Select · tab Complete +? for shortcutsGemini 3.8 Flash · high [?25h[?25l + + + + + + + + + +esc to cancel [?25h \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/antigravity-1-2-14-draft.meta.json b/src/main/runtime/__fixtures__/antigravity-1-2-14-draft.meta.json new file mode 100644 index 000000000000..a8160fc8de9b --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-1-2-14-draft.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T19:52:05.701Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy 1.2.14, macOS, Google account, AGY_CLI_HIDE_ACCOUNT_INFO=1, text typed into the composer and not submitted", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-1-2-14-draft.txt b/src/main/runtime/__fixtures__/antigravity-1-2-14-draft.txt new file mode 100644 index 000000000000..2779b2ffb94f --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-1-2-14-draft.txt @@ -0,0 +1,30 @@ +_Ga=q,f=32,s=1,v=1,i=31;AAAAAA==\[?2026$p[?2027$p[>4m[?1049h[?25l[?5W[?2004h[>4;2m[>1u[?u +▄▀▀▄ +▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀ + ▄▀▀ ▀▀▄ + ▄▀▀ ▀▀▄ + + Welcome back! You are currently not signed in. + + ⣷ Signing in...[>4m[<1u[?1049l[>4;2m[>1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.14 +▀▀▀▀▀▀ ~/orca/workspaces/orca/fix-wait-detector-scrollback +▀▀▀▀▀▀▀▀ +▄▀▀ ▀▀▄ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcuts [?25h[?25l Gemini 3.8 Flash (High) +~/orca/workspaces/orca/fix-wait-detector-scrollback + + + + + + +Gemini 3.8 Flash · high [?25h[?25lsu + + G[?25h[?25lmmari[?25h[?25lze the r[?25h[?25lepo[?25h \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/antigravity-1-2-14-model-picker.meta.json b/src/main/runtime/__fixtures__/antigravity-1-2-14-model-picker.meta.json new file mode 100644 index 000000000000..b35ce448a5c1 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-1-2-14-model-picker.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T19:09:17.386Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy 1.2.14, macOS, Google account, AGY_CLI_HIDE_ACCOUNT_INFO=1, /model picker left open", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-1-2-14-model-picker.txt b/src/main/runtime/__fixtures__/antigravity-1-2-14-model-picker.txt new file mode 100644 index 000000000000..afc34ace965c --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-1-2-14-model-picker.txt @@ -0,0 +1,64 @@ +_Ga=q,f=32,s=1,v=1,i=31;AAAAAA==\[?2026$p[?2027$p[>4m[?1049h[?25l[?5W[?2004h[>4;2m[>1u[?u +▄▀▀▄ +▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀ + ▄▀▀ ▀▀▄ + ▄▀▀ ▀▀▄ + + Welcome back! You are currently not signed in. + + ⣷ Signing in...[>4m[<1u[?1049l[>4;2m[>1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.14 +▀▀▀▀▀▀ ~/orca/workspaces/orca/fix-wait-detector-scrollback +▀▀▀▀▀▀▀▀ +▄▀▀ ▀▀▄ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcuts [?25h[?25l Gemini 3.8 Flash (High) +~/orca/workspaces/orca/fix-wait-detector-scrollback + + + + + + +Gemini 3.8 Flash · high [?25h[?25l/mod + +> /model Set a model, or run a single prompt on another model + /remote-client-runtime-debugging Diagnose and verify Orca paired web/mobile remote-client runtime parity bugs. + /memory-leak-auditAudit code for memory leaks and disposable issues. + /telemetry-product-gate Add or review product telemetry with a strict product-decision, dashboard-worthi... + + ↑/↓ Navigate · enter Select · tab Complete +esc to cancelGemini 3.8 Flash · high [?25h[?25l + + + + +/model + +  + + ↑/↓ Navigate · enter Select · tab Complete +esc to cancelGemini 3.8 Flash · high [?25h[?25l[0 q + +Switch Model + + Search:   + ──────────────────── + +> Gemini 3.8 Flash (current) + Gemini 3.7 Flash + Gemini 3.6 Flash + Gemini 3.1 Pro + + Effort ◂  ●━━━━━━━━━━━━━━●━━━━━━━━━━━━━━◉  ▸ +  low medium  high  + Deepest reasoning for complex problems — slower but strongest + +Keyboard: ↑/↓ Navigate ←/→ Effort enter Select esc Go Back + + Gemini 3.8 Flash · high  \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/antigravity-1-2-14-picker-dismissed.meta.json b/src/main/runtime/__fixtures__/antigravity-1-2-14-picker-dismissed.meta.json new file mode 100644 index 000000000000..7cabe6f7206f --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-1-2-14-picker-dismissed.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T19:09:49.792Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy 1.2.14, macOS, Google account, AGY_CLI_HIDE_ACCOUNT_INFO=1, /model picker opened then dismissed with Esc", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-1-2-14-picker-dismissed.txt b/src/main/runtime/__fixtures__/antigravity-1-2-14-picker-dismissed.txt new file mode 100644 index 000000000000..e16669fcf853 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-1-2-14-picker-dismissed.txt @@ -0,0 +1,76 @@ +_Ga=q,f=32,s=1,v=1,i=31;AAAAAA==\[?2026$p[?2027$p[>4m[?1049h[?25l[?5W[?2004h[>4;2m[>1u[?u +▄▀▀▄ +▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀ + ▄▀▀ ▀▀▄ + ▄▀▀ ▀▀▄ + + Welcome back! You are currently not signed in. + + ⣷ Signing in...[>4m[<1u[?1049l[>4;2m[>1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.14 +▀▀▀▀▀▀ ~/orca/workspaces/orca/fix-wait-detector-scrollback +▀▀▀▀▀▀▀▀ +▄▀▀ ▀▀▄ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcuts [?25h[?25l Gemini 3.8 Flash (High) +~/orca/workspaces/orca/fix-wait-detector-scrollback + + + + + + +Gemini 3.8 Flash · high [?25h[?25l/mo + +> /model Set a model, or run a single prompt on another model + /mobile-app-store-release Cut an Orca Mobile release: Use for "release Orca Mobile", "submit the iOS app f... + /mobile-backcompat-review Review Orca Mobile AND mobile-facing desktop changes for backwards compatibility... + /ui-html-mockCreate disposable HTML mocks for non-trivial UI planning before implementation. ... + /orca-mobile-emulator-qa Launch and validate Orca Mobile in an Android emulator or Orca-managed iOS Simul... + ↓ 12 more + + ↑/↓ Navigate · enter Select · tab Complete + Gemini 3.8 Flash · high [?25h[?25l + + + + +/model + +  + + ↑/↓ Navigate · enter Select · tab Complete +esc to cancelGemini 3.8 Flash · high [?25h[?25l + + + +esc to cancelGemini 3.8 Flash · high [?25h[?25l[0 q + +Switch Model + + Search:   + ──────────────────── + +> Gemini 3.8 Flash (current) + Gemini 3.7 Flash + Gemini 3.6 Flash + Gemini 3.1 Pro + + Effort ◂  ●━━━━━━━━━━━━━━●━━━━━━━━━━━━━━◉  ▸ +  low medium  high  + Deepest reasoning for complex problems — slower but strongest + +Keyboard: ↑/↓ Navigate ←/→ Effort enter Select esc Go Back + +? for shortcutsGemini 3.8 Flash · high  G[0 q > /model + ⎿ Exited /model command + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcutsGemini 3.8 Flash · high [?25h \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/antigravity-1-2-14-ready-80x24.meta.json b/src/main/runtime/__fixtures__/antigravity-1-2-14-ready-80x24.meta.json new file mode 100644 index 000000000000..473cc219a4e7 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-1-2-14-ready-80x24.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T19:09:01.181Z", + "platform": "darwin", + "command": ["agy"], + "cols": 80, + "rows": 24, + "note": "agy 1.2.14, macOS, Google account, AGY_CLI_HIDE_ACCOUNT_INFO=1, 80x24, settled startup", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-1-2-14-ready-80x24.txt b/src/main/runtime/__fixtures__/antigravity-1-2-14-ready-80x24.txt new file mode 100644 index 000000000000..04191f82366e --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-1-2-14-ready-80x24.txt @@ -0,0 +1,20 @@ +_Ga=q,f=32,s=1,v=1,i=31;AAAAAA==\[?2026$p[?2027$p[>4m[?1049h[?25l[?5W[?2004h[>4;2m[>1u[?u +▄▀▀▄ +▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀ + ▄▀▀ ▀▀▄ + ▄▀▀ ▀▀▄ + + Welcome back! You are currently not signed in. + + ⣷ Signing in... ⣯ [>4m[<1u[?1049l[>4;2m[>1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.14 +▀▀▀▀▀▀ Gemini 3.8 Flash (High) +▀▀▀▀▀▀▀▀ ~/orca/workspaces/orca/fix-wait-detector-scrollback + ▄▀▀ ▀▀▄ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────── +? for shortcutsGemini 3.8 Flash · high [?25h \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/antigravity-1-2-14-ready-accept-edits.meta.json b/src/main/runtime/__fixtures__/antigravity-1-2-14-ready-accept-edits.meta.json new file mode 100644 index 000000000000..52f1ce92d9cf --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-1-2-14-ready-accept-edits.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T19:08:32.811Z", + "platform": "darwin", + "command": ["agy", "--mode", "accept-edits"], + "cols": 120, + "rows": 40, + "note": "agy 1.2.14, macOS, Google account, AGY_CLI_HIDE_ACCOUNT_INFO=1, --mode accept-edits, settled startup", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-1-2-14-ready-accept-edits.txt b/src/main/runtime/__fixtures__/antigravity-1-2-14-ready-accept-edits.txt new file mode 100644 index 000000000000..9ef4bb060116 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-1-2-14-ready-accept-edits.txt @@ -0,0 +1,28 @@ +_Ga=q,f=32,s=1,v=1,i=31;AAAAAA==\[?2026$p[?2027$p[>4m[?1049h[?25l[?5W[?2004h[>4;2m[>1u[?u +▄▀▀▄ +▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀ + ▄▀▀ ▀▀▄ + ▄▀▀ ▀▀▄ + + Welcome back! You are currently not signed in. + + ⣷ Signing in...[>4m[<1u[?1049l[>4;2m[>1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.14 +▀▀▀▀▀▀ ~/orca/workspaces/orca/fix-wait-detector-scrollback +▀▀▀▀▀▀▀▀ +▄▀▀ ▀▀▄ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> Accept-edits mode: file edits auto-approved (shift+tab to cycle) +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcutsaccept-edits [?25h[?25l Gemini 3.8 Flash (High) +~/orca/workspaces/orca/fix-wait-detector-scrollback + + + + + + + accept-edits · Gemini 3.8 Flash · high [?25h \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/antigravity-1-2-14-ready-plan.meta.json b/src/main/runtime/__fixtures__/antigravity-1-2-14-ready-plan.meta.json new file mode 100644 index 000000000000..cee669db139b --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-1-2-14-ready-plan.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T19:08:46.997Z", + "platform": "darwin", + "command": ["agy", "--mode", "plan"], + "cols": 120, + "rows": 40, + "note": "agy 1.2.14, macOS, Google account, AGY_CLI_HIDE_ACCOUNT_INFO=1, --mode plan, settled startup", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-1-2-14-ready-plan.txt b/src/main/runtime/__fixtures__/antigravity-1-2-14-ready-plan.txt new file mode 100644 index 000000000000..57c940c8e688 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-1-2-14-ready-plan.txt @@ -0,0 +1,28 @@ +_Ga=q,f=32,s=1,v=1,i=31;AAAAAA==\[?2026$p[?2027$p[>4m[?1049h[?25l[?5W[?2004h[>4;2m[>1u[?u +▄▀▀▄ +▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀ + ▄▀▀ ▀▀▄ + ▄▀▀ ▀▀▄ + + Welcome back! You are currently not signed in. + + ⣷ Signing in...[>4m[<1u[?1049l[>4;2m[>1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.14 +▀▀▀▀▀▀ ~/orca/workspaces/orca/fix-wait-detector-scrollback +▀▀▀▀▀▀▀▀ +▄▀▀ ▀▀▄ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> Plan mode: research & plan only (shift+tab to cycle) +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcutsplan [?25h[?25l Gemini 3.8 Flash (High) +~/orca/workspaces/orca/fix-wait-detector-scrollback + + + + + + +plan · Gemini 3.8 Flash · high [?25h \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/antigravity-1-2-14-ready.meta.json b/src/main/runtime/__fixtures__/antigravity-1-2-14-ready.meta.json new file mode 100644 index 000000000000..dddd2bc75227 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-1-2-14-ready.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T19:08:07.377Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy 1.2.14 (banner), macOS, Google account, AGY_CLI_HIDE_ACCOUNT_INFO=1, settled startup", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-1-2-14-ready.txt b/src/main/runtime/__fixtures__/antigravity-1-2-14-ready.txt new file mode 100644 index 000000000000..d9e2ad604d2f --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-1-2-14-ready.txt @@ -0,0 +1,20 @@ +_Ga=q,f=32,s=1,v=1,i=31;AAAAAA==\[?2026$p[?2027$p[>4m[?1049h[?25l[?5W[?2004h[>4;2m[>1u[?u +▄▀▀▄ +▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀ + ▄▀▀ ▀▀▄ + ▄▀▀ ▀▀▄ + + Welcome back! You are currently not signed in. + + ⣾ Signing in... ⣷ ⣯ ⣟ ⡿ ⢿ ⣻ ⣽ ⣾ ⣷ [>4m[<1u[?1049l[>4;2m[>1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.14 +▀▀▀▀▀▀ Gemini 3.8 Flash (High) +▀▀▀▀▀▀▀▀ ~/orca/workspaces/orca/fix-wait-detector-scrollback + ▄▀▀ ▀▀▄ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcutsGemini 3.8 Flash · high [?25h \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/antigravity-1-2-14-trust-dialog.meta.json b/src/main/runtime/__fixtures__/antigravity-1-2-14-trust-dialog.meta.json new file mode 100644 index 000000000000..b635b1f56d12 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-1-2-14-trust-dialog.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T19:10:55.053Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy 1.2.14, macOS, Google account, AGY_CLI_HIDE_ACCOUNT_INFO=1, untrusted folder, trust dialog left unanswered", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-1-2-14-trust-dialog.txt b/src/main/runtime/__fixtures__/antigravity-1-2-14-trust-dialog.txt new file mode 100644 index 000000000000..b98b08240a78 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-1-2-14-trust-dialog.txt @@ -0,0 +1,21 @@ +_Ga=q,f=32,s=1,v=1,i=31;AAAAAA==\[?2026$p[?2027$p[>4m[?1049h[?25l[?5W[?2004h[>4;2m[>1u[?u +▄▀▀▄ +▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀ + ▄▀▀ ▀▀▄ + ▄▀▀ ▀▀▄ + + Welcome back! You are currently not signed in. + + ⣷ Signing in...Accessing workspace: + +/Users/xxxxxx/orca-lanes/sta-8741-feasibility/ws-untrusted + +Do you trust the contents of this project? + +Antigravity CLI requires permission to read, edit, and execute files here. + +> Yes, I trust this folder + No, exit + + ↑/↓ Navigate · enter ConfirmGemini 3.8 Flash · high \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/antigravity-1-2-14-turn-ended.meta.json b/src/main/runtime/__fixtures__/antigravity-1-2-14-turn-ended.meta.json new file mode 100644 index 000000000000..0ec041053041 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-1-2-14-turn-ended.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T19:10:42.693Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy 1.2.14, macOS, Google account, AGY_CLI_HIDE_ACCOUNT_INFO=1, one short turn, recording stopped after it ended", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-1-2-14-turn-ended.txt b/src/main/runtime/__fixtures__/antigravity-1-2-14-turn-ended.txt new file mode 100644 index 000000000000..74f8f576f1ac --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-1-2-14-turn-ended.txt @@ -0,0 +1,224 @@ +_Ga=q,f=32,s=1,v=1,i=31;AAAAAA==\[?2026$p[?2027$p[>4m[?1049h[?25l[?5W[?2004h[>4;2m[>1u[?u +▄▀▀▄ +▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀ + ▄▀▀ ▀▀▄ + ▄▀▀ ▀▀▄ + + Welcome back! You are currently not signed in. + + ⣷ Signing in...[>4m[<1u[?1049l[>4;2m[>1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.14 +▀▀▀▀▀▀ ~/orca/workspaces/orca/fix-wait-detector-scrollback +▀▀▀▀▀▀▀▀ +▄▀▀ ▀▀▄ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcuts [?25h[?25l Gemini 3.8 Flash (High) +~/orca/workspaces/orca/fix-wait-detector-scrollback + + + + + + +Gemini 3.8 Flash · high [?25h[?25lWit + + G[?25h[?25lhout u[?25h[?25lsing any[?25h[?25l tools, repl[?25h[?25ly with the wo[?25h[?25lrd ok.[?25h[?25l + +? for shortcuts[?25h[?25lM +> Without using any tools, reply with the word ok. +⣷ Generating... +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +esc to cancelGemini 3.8 Flash · high [?25h[?25l ⣯ Generating... + +[?25h[?25l ⣟ Generating... + +[?25h[?25l ⡿ G + +[?25h[?25l ⢿ Genera + +[?25h[?25l ⣻ Generating. + +[?25h[?25l ⣽ Generating... + +[?25h[?25l ⣾ Generating... + +[?25h[?25l ⣷ Ge + +[?25h[?25l ⣯ Generat + +[?25h[?25l ⣟ Generating.. + +[?25h[?25l ⡿ Generating... + +[?25h[?25l ⢿ Generating... + +[?25h[?25l ⣻ Gene + +[?25h[?25l ⣽ Generati + +[?25h[?25l ⣾ Generating... + +[?25h[?25l ⣷ Generating... + +[?25h[?25l ⣯ + +[?25h[?25l ⣟ Gener + +[?25h[?25l ⡿ Generating + +[?25h[?25l ⢿ Generating... + +[?25h[?25lg. + +[?25h[?25l ⣻ Generating... + +[?25h[?25l ⣽ + +[?25h[?25l ⣾ Genera + +[?25h[?25l ⣷ Generating. + +[?25h[?25l ⣯ Generating... + +[?25h[?25l ⣟ Generating... + +[?25h[?25l ⡿ Ge + +[?25h[?25l ⢿ Generat + +[?25h[?25l ⣻ Generating.. + +[?25h[?25ling... + +[?25h[?25l ⣽ Generating.. + +[?25h[?25l ⣾ Generating... + +[?25h[?25l ⣷ Gen + +[?25h[?25l ⣯ Generati + +[?25h[?25l ⣟ Generating... + +[?25h[?25l ⡿ Generating... + +[?25h[?25l ⢿ + +[?25h[?25l ⣻ Gene + +[?25h[?25l ⣽ Generatin + +[?25h[?25l ⣾ Generating... + +[?25h[?25l ⣷ Generating... + +[?25h[?25l ⣯ + +[?25h[?25l ⣟ Gener + +[?25h[?25l ⡿ Generating + +[?25h[?25l ⢿ Generating... + +[?25h[?25l ⣻ Generating... + +[?25h[?25l ⣽ G + +[?25h[?25l ⣾ Genera + +[?25h[?25l ⣷ Generating. + +[?25h[?25l ⣯ Generating... + +[?25h[?25l ⣟ Generating... + +[?25h[?25l ⡿ Ge + +[?25h[?25l ⢿ Generat + +[?25h[?25l ⣻ Generating.. + +[?25h[?25l ⣽ Generating... + +[?25h[?25l ⣾ Generating... + +[?25h[?25l ⣷ Gen + +[?25h[?25l ⣯ Generati + +[?25h[?25l ⣟ Generating... + +[?25h[?25l ⡿ Generating... + +[?25h[?25l ⢿ + +[?25h[?25l ⣻ Gene + +[?25h[?25l ⣽ Generatin + +[?25h[?25l ⣾ Generating... + +[?25h[?25l ⣷ Generating... + +[?25h[?25l ⣯ + +[?25h[?25l ⣟ Gener + +[?25h[?25l ⡿ Generating + +[?25h[?25l ⢿ Generating... + +[?25h[?25l ⣻ Generating... + +[?25h[?25l ⣽ G + +[?25h[?25l ⣾ Genera + +[?25h[?25l ⣷ Generating. + +[?25h[?25l ⣯ Generating... + +[?25h[?25l ⣟ Generating... + +[?25h[?25l ⡿ Ge + +[?25h[?25l ⢿ Generat + +[?25h[?25lnerati +└ Tip: In request-review mode, file edits are shown for approval before being applied. +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +esc to cancelGemini 3.8 Flash · high [?25h[?25l  + ok +⢿ Generating... +└ Tip: In request-review mode, file edits are shown for approval before being applied. + +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +esc to cancelGemini 3.8 Flash · high [?25h[?25lrating + + +[?25h[?25l ⣻ Generating.. + + +[?25h[?25ling... + + +[?25h[?25l +ng.. +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +esc to cancelGemini 3.8 Flash · high [?25h[?25l  + + + +? for shortcuts[?25h \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/cline-3-0-65-win32-startup.meta.json b/src/main/runtime/__fixtures__/cline-3-0-65-win32-startup.meta.json new file mode 100644 index 000000000000..4ce48df16702 --- /dev/null +++ b/src/main/runtime/__fixtures__/cline-3-0-65-win32-startup.meta.json @@ -0,0 +1,18 @@ +{ + "capturedAt": "2026-09-26T20:48:58.276Z", + "platform": "win32", + "command": [ + "node", + "", + "--provider", + "openrouter", + "--model", + "xiaomi/mimo-v2.6-pro", + "--auto-approve", + "true" + ], + "cols": 120, + "rows": 40, + "note": "Cline 3.0.65; OpenRouter MiMo 2.6 Pro; startup only, no model prompt; Escape dismisses promotion if present; recorder uses installed node-pty 1.1.0. Recorded by a contributor for PR #23269.", + "exitCode": -1073741510 +} diff --git a/src/main/runtime/__fixtures__/cline-3-0-65-win32-startup.txt b/src/main/runtime/__fixtures__/cline-3-0-65-win32-startup.txt new file mode 100644 index 000000000000..ded729996f0b --- /dev/null +++ b/src/main/runtime/__fixtures__/cline-3-0-65-win32-startup.txt @@ -0,0 +1,40 @@ +[?9001h[?1004h[?2031h[>0q[?u]99;i=opentui-notifications:p=?;\]0;C:\Program Files\nodejs\node.exe[?25l]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2026h[?2026l ]0;Cline[?2026h]12;#79b8ff[1 q[?2026l@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*@@@@@@@@@@@@@@@*@@@@@@@@@@@@@@@@@@.@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@.What can I do for you?Use / for slash commands, @ for file mentions, Ctrl+P for menu────────────────────────────────────────────────────────────────────❯What can I do for you?────────────────────────────────────────────────────────────────────MiMo-V2.6-Pro (medium) ██████ (0) $0.00○ Plan● Act(Tab)cline-startup-readiness(fix/cline-startup-readiness)⏵⏵ Auto-approve all enabled (Shift+Tab)[?25h \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/cline-3-0-66-busy-streaming.meta.json b/src/main/runtime/__fixtures__/cline-3-0-66-busy-streaming.meta.json new file mode 100644 index 000000000000..52d3cf586596 --- /dev/null +++ b/src/main/runtime/__fixtures__/cline-3-0-66-busy-streaming.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T19:12:26.788Z", + "platform": "darwin", + "command": ["cline", "--config", "/cline-or", "--data-dir", "/cline-or/data"], + "cols": 120, + "rows": 40, + "note": "cline 3.0.66, macOS, OpenRouter free router, isolated --config/--data-dir, promo dismissed, one turn, recording stopped while the turn was still running. Truncated 89003 -> 35988 bytes, after the first frame whose spinner row has scrolled off.", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/cline-3-0-66-busy-streaming.txt b/src/main/runtime/__fixtures__/cline-3-0-66-busy-streaming.txt new file mode 100644 index 000000000000..1c4edab8b1c2 --- /dev/null +++ b/src/main/runtime/__fixtures__/cline-3-0-66-busy-streaming.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?]0;Cline[?2026h[?25l      @@  @@@@@@  @@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@@@  @@@@@ @@@@ @@@@@  @@@@@@ *@@@ @@@@@@  @@@@@@ *@@@ @@@@@@  @@@@@ @@@@. @@@@@  @@@@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@.   What can I do for you?   Use / for slash commands, @ for file mentions, Ctrl+P for menu   ────────────────────────────────────────────────────────────────────  ❯ What can I do for you?  ────────────────────────────────────────────────────────────────────   Free Models Router ██████ (0) $0.00 ○ Plan ● Act (Tab)  ws  ⏵⏵ Auto-approve all enabled (Shift+Tab)            ]12;#79b8ff[1 q[?25h[?2026l[?2026h[?25lWithout using any tools, write the numbers 1 to 400 in chat, one  per line. ────────────────────────────────────────────────────────────────────    Free Models Router ██████ (0) $0.00○ Plan● Act(Tab)ws ⏵⏵ Auto-approve all enabled (Shift+Tab)[?25h[?2026l[?2026h[?25l @@@@  # +@ @@@ @@@@@@@@@@@. @+ . @@@@@@@@@@ @* * @@@@@@@@@@ ;@ # @@@@@@@ @: .  @@@ @@@@; [?25h[?2026l[?2026h[?25l@#@*@@@@@@@@@@@:@.#..[?25h[?2026l[?2026h[?25l.  #@:+.# +@.:*@[?25h[?2026l[?2026h[?25l;+#@@:@@ + @* @ .@@. @@#[?25h[?2026l[?2026h[?25l:+@:#@:@+*   #@. ##:[?25h[?2026l[?2026h[?25l ❯ Without using any tools, write the numbers 1 to 400 in chat, one per line.   ⠋Thinking... (esc to cancel)                                █ ██ ██ █  █ █ ██    █ █ █████────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────❯Ask anything...────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────Free Models Router ██████ (0) $0.00○ Plan● Act(Tab)ws⏵⏵ Auto-approve all enabled (Shift+Tab)[?25h[?2026l]0;> Without using any tools, write the numbers 1 to 400 in chat, one per line.[?2026h[?25l  ❯ Without using any tools, write the numbers 1 to 400 in chat, one per line.     ⠋Thinking... (esc to cancel)                               [?25h[?2026l[?2026h[?25l [?25h[?2026l[?2026h[?25l⠙[?25h[?2026l[?2026h[?25l⠹[?25h[?2026l[?2026h[?25l⠸[?25h[?2026l[?2026h[?25l⠼[?25h[?2026l[?2026h[?25l⠴[?25h[?2026l[?2026h[?25l⠦[?25h[?2026l[?2026h[?25l⠧[?25h[?2026l[?2026h[?25l⠇[?25h[?2026l[?2026h[?25l⠋Thinking... The user is asking me[?25h[?2026l[?2026h[?25l to write numbers 1 to 400 in[?25h[?2026l[?2026h[?25l⠙ chat, one[?25h[?2026l[?2026h[?25l per line, without using any tools. Looking[?25h[?2026l[?2026h[?25l⠹[?25h[?2026l[?2026h[?25l⠸[?25h[?2026l[?2026h[?25l at the rules, I'm told that if the[?25h[?2026l[?2026h[?25l⠼ user asks a simple question without any coding context, I[?25h[?2026l[?2026h[?25l should answer it directly without using any tools. This[?25h[?2026l[?2026h[?25l⠴[?25h[?2026l[?2026h[?25l is clearly a[?25h[?2026l[?2026h[?25l simple request - they want me[?25h[?2026l[?2026h[?25l⠦[?25h[?2026l[?2026h[?25l to display[?25h[?2026l[?2026h[?25l⠧ numbers 1-400, one per line.[?25h[?2026l[?2026h[?25l This doesn't require any tools, code analysis, or[?25h[?2026l[?2026h[?25l⠇[?25h[?2026l[?2026h[?25l⠏[?25h[?2026l[?2026h[?25l complex operations. It's just a straightforward text output[?25h[?2026l[?2026h[?25l request. I should comply with[?25h[?2026l[?2026h[?25l⠋ this request directly[?25h[?2026l[?2026h[?25l⠙[?25h[?2026l[?2026h[?25l by listing the numbers from 1 to 400,[?25h[?2026l[?2026h[?25l⠹[?25h[?2026l[?2026h[?25l each on a separate line[?25h[?2026l[?2026h[?25l▶ : ..ould comply with this request directly by listing the numbers from 1 to 400, each on a separate line. ⠋I'll write the numbers       [?25h[?2026l[?2026h[?25l⠙1to400foryou,oneperline:[?25h[?2026l[?2026h[?25l⠹[?25h[?2026l[?2026h[?25l12345678910[?25h[?2026l[?2026h[?25l⠸[?25h[?2026l[?2026h[?25l⠼[?25h[?2026l[?2026h[?25l111213141516171819[?25h[?2026l[?2026h[?25l⠴[?25h[?2026l[?2026h[?25l⠦[?25h[?2026l[?2026h[?25l▄❯ Without using any tools, write the numbers 1 to 400 in chat, one per line.  ▶ Thinking: ...ould comply with this request directly by listing the numbers from 1 to 400, each on a separate line. ⠦I'llwritethenumbers1to400foryou,oneperline:             123456789101234567892021222324252627[?25h[?2026l[?2026h[?25l ▶ Thinking: ...ould comply with this request directly by listing the numbers from 1 to 400, each on a separate line.   ⠦ I'll write the numbers 1 to 400 for you, one per line:    1             2 3 4 5 6 7 8 9 10 11 2 3 4 5 6 7 8 9 20 21 2▄3█4█5█6█7█8█9██[?25h[?2026l[?2026h[?25l⠧[?25h[?2026l[?2026h[?25l⠇[?25h[?2026l[?2026h[?25l⠇ I'll write the numbers 1 to 400 for you, one per line:  1            234567891011234567892021234 5 6 7▄893031[?25h[?2026l[?2026h[?25l 6            78910111213141516789202122232425267893031323334 35 36 7▄8[?25h \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/cline-3-0-66-draft.meta.json b/src/main/runtime/__fixtures__/cline-3-0-66-draft.meta.json new file mode 100644 index 000000000000..f559bf60e6f8 --- /dev/null +++ b/src/main/runtime/__fixtures__/cline-3-0-66-draft.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T19:51:53.501Z", + "platform": "darwin", + "command": ["cline", "--config", "/cline-or", "--data-dir", "/cline-or/data"], + "cols": 120, + "rows": 40, + "note": "cline 3.0.66, macOS, OpenRouter free router, isolated --config/--data-dir, text typed into the composer and not submitted", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/cline-3-0-66-draft.txt b/src/main/runtime/__fixtures__/cline-3-0-66-draft.txt new file mode 100644 index 000000000000..3543c8171dfe --- /dev/null +++ b/src/main/runtime/__fixtures__/cline-3-0-66-draft.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?]0;Cline[?2026h[?25l      @@  @@@@@@  @@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@@@  @@@@@ @@@@ @@@@@  @@@@@@ *@@@ @@@@@@  @@@@@@ *@@@ @@@@@@  @@@@@ @@@@. @@@@@  @@@@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@.   What can I do for you?   Use / for slash commands, @ for file mentions, Ctrl+P for menu   ────────────────────────────────────────────────────────────────────  ❯ Plan something...  ────────────────────────────────────────────────────────────────────   Free Models Router ██████ (0) $0.00 ● Plan ○ Act (Tab)  ws  Auto-approve all disabled (Shift+Tab)            ]12;#ffea7f[1 q[?25h[?2026l[?2026h[?25lsummarize the repo[?25h[?2026l[?2026h[?25l @@@@ ;: ;@# #@@@ @@@@@@@@@@@@ +@.  :@@@@@@@@@ @.  @@@@@@@@@ @# # @@@@@@ @. #  @@@ @@@@ [?25h[?2026l[?2026h[?25l. #+@  . #@@@@@@@@@@.@# ;@:.@@   [?25h[?2026l[?2026h[?25l: @ @*@+@@@+#; #*.+@@ @@# @@#@[?25h[?2026l[?2026h[?25l @: @:.##@+@@#@@## @@# @:# @@ @# @@@#@@.##[?25h[?2026l[?2026h[?25l##@+@@#;:##*@+@ # @##.@@*@ .[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/cline-3-0-66-permission.meta.json b/src/main/runtime/__fixtures__/cline-3-0-66-permission.meta.json new file mode 100644 index 000000000000..1c769726b601 --- /dev/null +++ b/src/main/runtime/__fixtures__/cline-3-0-66-permission.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T19:21:48.563Z", + "platform": "darwin", + "command": ["cline", "--config", "/cline-or", "--data-dir", "/cline-or/data"], + "cols": 120, + "rows": 40, + "note": "cline 3.0.66, macOS, OpenRouter free router, isolated --config/--data-dir, auto-approve turned off with Shift+Tab, a turn that runs a shell command; approval prompt left unanswered", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/cline-3-0-66-permission.txt b/src/main/runtime/__fixtures__/cline-3-0-66-permission.txt new file mode 100644 index 000000000000..bb24a60a5b40 --- /dev/null +++ b/src/main/runtime/__fixtures__/cline-3-0-66-permission.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?]0;Cline[?2026h[?25l      @@  @@@@@@  @@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@@@  @@@@@ @@@@ @@@@@  @@@@@@ *@@@ @@@@@@  @@@@@@ *@@@ @@@@@@  @@@@@ @@@@. @@@@@  @@@@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@.   What can I do for you?   Use / for slash commands, @ for file mentions, Ctrl+P for menu   ────────────────────────────────────────────────────────────────────  ❯ What can I do for you?  ────────────────────────────────────────────────────────────────────   Free Models Router ██████ (0) $0.00 ○ Plan ● Act (Tab)  ws  ⏵⏵ Auto-approve all enabled (Shift+Tab)            ]12;#79b8ff[1 q[?25h[?2026l[?2026h[?25lAuto-approve all disabled (Shift+Tab) [?25h[?2026l[?2026h[?25lUse your shell tool to run: rm note.txt[?25h[?2026l[?2026h[?25l; @@@+  # @@ @@@ @@@@@@@@@@@: @@@@@@@@@@@@ @+@@@@@@@@ *@.  @@@@@. @@ .  @@@ #@@@@ [?25h[?2026l[?2026h[?25l @* @@*+@@@@ @ @@ ##@@ @#  @@ @+ @@ @@@# @@@@@@@+@# ;@*[?25h[?2026l[?2026h[?25l;@; @## # @@###*@.@# #@@# @ @@ @@@ # + :@@ @@[?25h[?2026l[?2026h[?25l @@ @ @ @ @@#@@@@@ @@ ;@ @# @@ @ @ @ @+[?25h[?2026l[?2026h[?25l#*.@@#@#@ @ @*;@ @@@ @[?25h[?2026l[?2026h[?25l ❯ Use your shell tool to run: rm note.txt   ⠋Thinking... (esc to cancel)                               █ ██ ██ █  █ ██    █ █ ██████────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────❯Ask anything...────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────Free Models Router ██████ (0) $0.00○ Plan● Act(Tab)wsAuto-approve all disabled (Shift+Tab)[?25h[?2026l]0;> Use your shell tool to run: rm note.txt[?2026h[?25l  ❯ Use your shell tool to run: rm note.txt     ⠋Thinking... (esc to cancel)                               [?25h[?2026l[?2026h[?25l [?25h[?2026l[?2026h[?25l⠙[?25h[?2026l[?2026h[?25l⠹[?25h[?2026l[?2026h[?25l⠸[?25h[?2026l[?2026h[?25l⠼[?25h[?2026l[?2026h[?25l⠴[?25h[?2026l[?2026h[?25l⠦[?25h[?2026l[?2026h[?25l⠧[?25h[?2026l[?2026h[?25l⠇[?25h[?2026l[?2026h[?25l⠏[?25h[?2026l[?2026h[?25l⠋[?25h[?2026l[?2026h[?25l⠙[?25h[?2026l[?2026h[?25l⠹[?25h[?2026l[?2026h[?25l⠸[?25h[?2026l[?2026h[?25l⠋Thinking... The[?25h[?2026l[?2026h[?25l user wants me to[?25h[?2026l[?2026h[?25l⠙[?25h[?2026l[?2026h[?25l run a shell command to remove[?25h[?2026l[?2026h[?25l⠹[?25h[?2026l[?2026h[?25l a file called note[?25h[?2026l[?2026h[?25l⠸[?25h[?2026l[?2026h[?25l.txt. Let me do that.[?25h[?2026l[?2026h[?25l⠼[?25h[?2026l[?2026h[?25l⠴[?25h[?2026l[?2026h[?25l⠦[?25h[?2026l[?2026h[?25l▶ : The user wants me to run a shell command to remove a file called note.txt. Let me do that.   Cline needs permission   Approve tool call?   run_commands    {   "commands": "rm note.txt"  }    [y] Approve  [n] Deny   █5,608) $0.00[?25h[?2026l[?2026h[?25l[?25l[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/cline-3-0-66-promo.meta.json b/src/main/runtime/__fixtures__/cline-3-0-66-promo.meta.json new file mode 100644 index 000000000000..f97fa657a6a2 --- /dev/null +++ b/src/main/runtime/__fixtures__/cline-3-0-66-promo.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T19:11:43.778Z", + "platform": "darwin", + "command": ["cline", "--config", "/cline-or", "--data-dir", "/cline-or/data"], + "cols": 120, + "rows": 40, + "note": "cline 3.0.66, macOS, OpenRouter free router, isolated --config/--data-dir, ClinePass promo popup left open", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/cline-3-0-66-promo.txt b/src/main/runtime/__fixtures__/cline-3-0-66-promo.txt new file mode 100644 index 000000000000..eb1145e60a83 --- /dev/null +++ b/src/main/runtime/__fixtures__/cline-3-0-66-promo.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?]0;Cline[?2026h[?25l      @@  @@@@@@  @@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@@@  @@@@@ @@@@ @@@@@  @@@@@@ *@@@ @@@@@@  @@@@@@ *@@@ @@@@@@  @@@@@ @@@@. @@@@@  @@@@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@.   What can I do for you?   Use / for slash commands, @ for file mentions, Ctrl+P for menu   ────────────────────────────────────────────────────────────────────  ❯ What can I do for you?  ────────────────────────────────────────────────────────────────────   Free Models Router ██████ (0) $0.00 ○ Plan ● Act (Tab)  ws  ⏵⏵ Auto-approve all enabled (Shift+Tab)            ]12;#79b8ff[1 q[?25h[?2026l[?2026h[?25l      @@  @@@@@@  @@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@@@  @@@@@ @@@@ @@@@@  @@@@@@ *@@@ @@@@@@      Introducing Cline Desktop       A native app for working with open weights models. Use    it with ClinePass and our free models, or BYOK.    - Import tasks from Claude Code and Codex    - Run Cline on a regular schedule    - Use web search tool and voice input   U - Browse Marketplace for plugins, MCPs, and skills u   Available for macOS and Windows.   ──── ────  ❯ Wh ]8;id=65599;https://cline.bot/desktop\https://cline.bot/desktop]8;;\   ──── ────    Get Cline Desktop    Fre ab)  ws  Press Enter to open, any other key to close   ⏵⏵              [?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/cline-3-0-66-ready-80x24.meta.json b/src/main/runtime/__fixtures__/cline-3-0-66-ready-80x24.meta.json new file mode 100644 index 000000000000..6f64c7c0ed8c --- /dev/null +++ b/src/main/runtime/__fixtures__/cline-3-0-66-ready-80x24.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T19:27:58.447Z", + "platform": "darwin", + "command": ["cline", "--config", "/cline-or", "--data-dir", "/cline-or/data"], + "cols": 80, + "rows": 24, + "note": "cline 3.0.66, macOS, OpenRouter free router, isolated --config/--data-dir, 80x24, settled empty composer", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/cline-3-0-66-ready-80x24.txt b/src/main/runtime/__fixtures__/cline-3-0-66-ready-80x24.txt new file mode 100644 index 000000000000..745d1484b894 --- /dev/null +++ b/src/main/runtime/__fixtures__/cline-3-0-66-ready-80x24.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?]0;Cline[?2026h[?25l @@@@@@  @@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@@@  @@@@@@ *@@@ @@@@@@  @@@@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@.@   What can I do for you?   Use / for slash commands, @ for file mentions, Ctrl+P for menu   ────────────────────────────────────────────────────────────────────  ❯ Plan something...  ────────────────────────────────────────────────────────────────────   Free Models Router ██████ (0) $0.00 ● Plan ○ Act (Tab)  ws  Auto-approve all disabled (Shift+Tab)       ]12;#ffea7f[1 q[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/cline-3-0-66-ready-plan.meta.json b/src/main/runtime/__fixtures__/cline-3-0-66-ready-plan.meta.json new file mode 100644 index 000000000000..c53426aa52ca --- /dev/null +++ b/src/main/runtime/__fixtures__/cline-3-0-66-ready-plan.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T19:27:50.353Z", + "platform": "darwin", + "command": ["cline", "--config", "/cline-or", "--data-dir", "/cline-or/data"], + "cols": 120, + "rows": 40, + "note": "cline 3.0.66, macOS, OpenRouter free router, isolated --config/--data-dir, switched to Plan mode with Tab, settled empty composer", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/cline-3-0-66-ready-plan.txt b/src/main/runtime/__fixtures__/cline-3-0-66-ready-plan.txt new file mode 100644 index 000000000000..a597e2bab47f --- /dev/null +++ b/src/main/runtime/__fixtures__/cline-3-0-66-ready-plan.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?]0;Cline[?2026h[?25l      @@  @@@@@@  @@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@@@  @@@@@ @@@@ @@@@@  @@@@@@ *@@@ @@@@@@  @@@@@@ *@@@ @@@@@@  @@@@@ @@@@. @@@@@  @@@@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@.   What can I do for you?   Use / for slash commands, @ for file mentions, Ctrl+P for menu   ────────────────────────────────────────────────────────────────────  ❯ What can I do for you?  ────────────────────────────────────────────────────────────────────   Free Models Router ██████ (0) $0.00 ○ Plan ● Act (Tab)  ws  Auto-approve all disabled (Shift+Tab)            ]12;#79b8ff[1 q[?25h[?2026l[?2026h[?25l❯Plan something... ● Plan○ Act]12;#ffea7f[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/cline-3-0-66-ready.meta.json b/src/main/runtime/__fixtures__/cline-3-0-66-ready.meta.json new file mode 100644 index 000000000000..0e9b5d871218 --- /dev/null +++ b/src/main/runtime/__fixtures__/cline-3-0-66-ready.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T19:11:56.858Z", + "platform": "darwin", + "command": ["cline", "--config", "/cline-or", "--data-dir", "/cline-or/data"], + "cols": 120, + "rows": 40, + "note": "cline 3.0.66, macOS, OpenRouter free router, isolated --config/--data-dir, promo dismissed with Esc, settled empty composer", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/cline-3-0-66-ready.txt b/src/main/runtime/__fixtures__/cline-3-0-66-ready.txt new file mode 100644 index 000000000000..1213d59f4c5d --- /dev/null +++ b/src/main/runtime/__fixtures__/cline-3-0-66-ready.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?]0;Cline[?2026h[?25l      @@  @@@@@@  @@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@@@  @@@@@ @@@@ @@@@@  @@@@@@ *@@@ @@@@@@  @@@@@@ *@@@ @@@@@@  @@@@@ @@@@. @@@@@  @@@@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@.   What can I do for you?   Use / for slash commands, @ for file mentions, Ctrl+P for menu   ────────────────────────────────────────────────────────────────────  ❯ What can I do for you?  ────────────────────────────────────────────────────────────────────   Free Models Router ██████ (0) $0.00 ○ Plan ● Act (Tab)  ws  ⏵⏵ Auto-approve all enabled (Shift+Tab)            ]12;#79b8ff[1 q[?25h[?2026l[?2026h[?25l      @@  @@@@@@  @@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@@@  @@@@@ @@@@ @@@@@  @@@@@@ *@@@ @@@@@@      Introducing Cline Desktop       A native app for working with open weights models. Use    it with ClinePass and our free models, or BYOK.    - Import tasks from Claude Code and Codex    - Run Cline on a regular schedule    - Use web search tool and voice input   U - Browse Marketplace for plugins, MCPs, and skills u   Available for macOS and Windows.   ──── ────  ❯ Wh ]8;id=65599;https://cline.bot/desktop\https://cline.bot/desktop]8;;\   ──── ────    Get Cline Desktop    Fre ab)  ws  Press Enter to open, any other key to close   ⏵⏵              [?2026l[?2026h[?25l      @@  @@@@@@  @@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@@@  @@@@@ @@@@ @@@@@  @@@@@@ *@@@ @@@@@@  @@@@@@ *@@@ @@@@@@  @@@@@ @@@@. @@@@@  @@@@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@.   What can I do for you?   Use / for slash commands, @ for file mentions, Ctrl+P for menu   ────────────────────────────────────────────────────────────────────  ❯ What can I do for you?  ────────────────────────────────────────────────────────────────────   Free Models Router ██████ (0) $0.00 ○ Plan ● Act (Tab)  ws  ⏵⏵ Auto-approve all enabled (Shift+Tab)            ]12;#79b8ff[1 q[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/cline-3-0-66-slash-menu.meta.json b/src/main/runtime/__fixtures__/cline-3-0-66-slash-menu.meta.json new file mode 100644 index 000000000000..39276bc541b6 --- /dev/null +++ b/src/main/runtime/__fixtures__/cline-3-0-66-slash-menu.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T19:28:06.518Z", + "platform": "darwin", + "command": ["cline", "--config", "/cline-or", "--data-dir", "/cline-or/data"], + "cols": 120, + "rows": 40, + "note": "cline 3.0.66, macOS, OpenRouter free router, isolated --config/--data-dir, slash-command menu left open", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/cline-3-0-66-slash-menu.txt b/src/main/runtime/__fixtures__/cline-3-0-66-slash-menu.txt new file mode 100644 index 000000000000..b238bd77c5ee --- /dev/null +++ b/src/main/runtime/__fixtures__/cline-3-0-66-slash-menu.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?]0;Cline[?2026h[?25l      @@  @@@@@@  @@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@@@  @@@@@ @@@@ @@@@@  @@@@@@ *@@@ @@@@@@  @@@@@@ *@@@ @@@@@@  @@@@@ @@@@. @@@@@  @@@@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@.   What can I do for you?   Use / for slash commands, @ for file mentions, Ctrl+P for menu   ────────────────────────────────────────────────────────────────────  ❯ Plan something...  ────────────────────────────────────────────────────────────────────   Free Models Router ██████ (0) $0.00 ● Plan ○ Act (Tab)  ws  Auto-approve all disabled (Shift+Tab)            ]12;#ffea7f[1 q[?25h[?2026l[?2026h[?25l/ [?25h[?2026l[?2026h[?25l╭──────────────────────────────────────────────────────────────────╮│ ❯ /settings Modify agent configuration ││  /model Switch model or provider││  /theme    Change color theme││ /account View Cline account││ /mcp Manage MCP servers││ /plugins Manage plugins││▼ 22 more│╰──────────────────────────────────────────────────────────────────╯[?25h[?2026l[?2026h[?25l @@@@   *@ @@@ @@@@@@@@@@@@ @# @@@@@@@@ @# ;@@@@@@@ @@. #@@@@@ .@; # # @@@ ;@@@@; [?25h[?2026l[?2026h[?25l.#+  .@@@@@ @@@@@@@@ . @# ;@@  :@. @@@@    [?25h[?2026l[?2026h[?25l::.[?25h[?2026l[?2026h[?25l@#@*@@:@. # ..[?25h[?2026l[?2026h[?25l:.;@:;[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/cline-3-0-66-turn-ended.meta.json b/src/main/runtime/__fixtures__/cline-3-0-66-turn-ended.meta.json new file mode 100644 index 000000000000..675ee8e15a21 --- /dev/null +++ b/src/main/runtime/__fixtures__/cline-3-0-66-turn-ended.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T18:53:49.541Z", + "platform": "darwin", + "command": ["cline", "--config", "/cline-or", "--data-dir", "/cline-or/data"], + "cols": 120, + "rows": 40, + "note": "cline 3.0.66, macOS, OpenRouter free router, isolated --config/--data-dir, promo dismissed with Esc, one short turn, recording stopped after it ended", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/cline-3-0-66-turn-ended.txt b/src/main/runtime/__fixtures__/cline-3-0-66-turn-ended.txt new file mode 100644 index 000000000000..dd7a9b8c8b1c --- /dev/null +++ b/src/main/runtime/__fixtures__/cline-3-0-66-turn-ended.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?]0;Cline[?2026h[?25l      @@  @@@@@@  @@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@@@  @@@@@ @@@@ @@@@@  @@@@@@ *@@@ @@@@@@  @@@@@@ *@@@ @@@@@@  @@@@@ @@@@. @@@@@  @@@@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@.   What can I do for you?   Use / for slash commands, @ for file mentions, Ctrl+P for menu   ────────────────────────────────────────────────────────────────────  ❯ What can I do for you?  ────────────────────────────────────────────────────────────────────   Free Models Router ██████ (0) $0.00 ○ Plan ● Act (Tab)  ws  ⏵⏵ Auto-approve all enabled (Shift+Tab)            ]12;#79b8ff[1 q[?25h[?2026l[?2026h[?25l      @@  @@@@@@  @@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@@@  @@@@@ @@@@ @@@@@  @@@@@@ *@@@ @@@@@@  @@@@@@ *@@@ @@@@@@      Try ClinePass       ClinePass is a $9.99/month subscription plan to get    access to the latest open-weight coding models with    enough quota for day-to-day work, at a much lower cost    than paying API costs directly.   U u   ]8;id=65599;https://app.cline.bot/dashboard/subscription?personal=true\https://app.cline.bot/dashboard/subscription?]8;;\   ──── ────  ❯ Wh  Open ClinePass    ──── ────   Press Enter to open, any other key to close   Fre ab)  ws  ⏵⏵ Auto-approve all enabled (Shift+Tab)            [?2026l[?2026h[?25l      @@  @@@@@@  @@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@@@  @@@@@ @@@@ @@@@@  @@@@@@ *@@@ @@@@@@  @@@@@@ *@@@ @@@@@@  @@@@@ @@@@. @@@@@  @@@@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@@@@@  @@@@@@@@@@@@@@@@.   What can I do for you?   Use / for slash commands, @ for file mentions, Ctrl+P for menu   ────────────────────────────────────────────────────────────────────  ❯ What can I do for you?  ────────────────────────────────────────────────────────────────────   Free Models Router ██████ (0) $0.00 ○ Plan ● Act (Tab)  ws  ⏵⏵ Auto-approve all enabled (Shift+Tab)            ]12;#79b8ff[1 q[?25h[?2026l[?2026h[?25lreply with the word ok[?25h[?2026l[?2026h[?25l @@@@ *. ;@* #@@@ @@@@@@@@@@@@ +@.  @@@@@@@@@@ @. . @@@@@@@@@ @# * @@@@@@ @. #  @@@ @@@@ [?25h[?2026l[?2026h[?25l: #+@  : #@@@@@@@@@.@# .@:.@@   [?25h[?2026l[?2026h[?25l; @+ @#@@@@:@@ + @*. @@ .@@@. @@#@[?25h[?2026l[?2026h[?25l @: @:.##@+@#@## @# :# @@ @# @@#@@.##[?25h[?2026l[?2026h[?25l;@ @:#@@@*@ . #@#@ @ @. @@ @ @# @@ @@@@@@ @#[?25h[?2026l[?2026h[?25l ❯ reply with the word ok   ⠋Thinking... (esc to cancel)                               █ ██ ██ █  █ ██    █ █ ██████────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────❯Ask anything...────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────Free Models Router ██████ (0) $0.00○ Plan● Act(Tab)ws⏵⏵ Auto-approve all enabled (Shift+Tab)[?25h[?2026l]0;> reply with the word ok[?2026h[?25l  ❯ reply with the word ok     ⠋Thinking... (esc to cancel)                               [?25h[?2026l[?2026h[?25l [?25h[?2026l[?2026h[?25l⠙[?25h[?2026l[?2026h[?25l⠹[?25h[?2026l[?2026h[?25l⠸[?25h[?2026l[?2026h[?25l⠼[?25h[?2026l[?2026h[?25l⠴[?25h[?2026l[?2026h[?25l⠦[?25h[?2026l[?2026h[?25l⠧[?25h[?2026l[?2026h[?25l⠋Thinking... Okay[?25h[?2026l[?2026h[?25l⠙[?25h[?2026l[?2026h[?25l, the user is[?25h[?2026l[?2026h[?25l⠹ asking me to reply with just the word "ok[?25h[?2026l[?2026h[?25l". This seems like a simple test or confirmation request.  I need to follow[?25h[?2026l[?2026h[?25l the instructions carefully. The user wants me to respond[?25h[?2026l[?2026h[?25l⠸[?25h[?2026l[?2026h[?25l with exactly "ok" and nothing else. Since[?25h[?2026l[?2026h[?25l⠼[?25h[?2026l[?2026h[?25l this is a straightforward request without any coding context or[?25h[?2026l[?2026h[?25l need for tools, I should just provide[?25h[?2026l[?2026h[?25l⠴[?25h[?2026l[?2026h[?25l the word "ok" as instructed. I won[?25h[?2026l[?2026h[?25l⠦[?25h[?2026l[?2026h[?25l't use any tools for this because it[?25h[?2026l[?2026h[?25l's a simple text response. The user didn[?25h[?2026l[?2026h[?25l⠧[?25h[?2026l[?2026h[?25l't ask for any file operations[?25h[?2026l[?2026h[?25l⠇[?25h[?2026l[?2026h[?25l, searches, or other complex[?25h[?2026l[?2026h[?25l actions - just a basic reply. I'll[?25h[?2026l[?2026h[?25l⠏[?25h[?2026l[?2026h[?25l make sure my response contains only the word "[?25h[?2026l[?2026h[?25l⠋[?25h[?2026l[?2026h[?25lok" to comply exactly[?25h[?2026l[?2026h[?25l▶ : ..ic reply. I'll make sure my response contains only the word "ok" to comply exactly with the request. ⠋ok         [?25h[?2026l[?2026h[?25l*█6,573) $0.00[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/prime-agent-0-9-5-ready.meta.json b/src/main/runtime/__fixtures__/prime-agent-0-9-5-ready.meta.json new file mode 100644 index 000000000000..8ba1e155819b --- /dev/null +++ b/src/main/runtime/__fixtures__/prime-agent-0-9-5-ready.meta.json @@ -0,0 +1,17 @@ +{ + "capturedAt": "2026-09-22T05:11:36.159Z", + "platform": "darwin", + "command": [ + "prime-agent", + "--daemon-socket", + "/tmp/prime-patch-capture.sock", + "--provider", + "bifrost", + "--model", + "deepseek-v4.1-flash" + ], + "cols": 120, + "rows": 35, + "note": "Prime 0.9.5 bifrost deepseek-v4.1-flash idle. Recorded by a contributor for PR #22154.", + "exitCode": 129 +} diff --git a/src/main/runtime/__fixtures__/prime-agent-0-9-5-ready.txt b/src/main/runtime/__fixtures__/prime-agent-0-9-5-ready.txt new file mode 100644 index 000000000000..5bab71e795b1 --- /dev/null +++ b/src/main/runtime/__fixtures__/prime-agent-0-9-5-ready.txt @@ -0,0 +1 @@ +[?2004h]10;?\]11;?\[?u[?25l[?1049h[?25l[?1002h[?1006h[?2026h orca-readiness-fix]8;;]8;;  ▗▄▄█▀ ]8;;  ███▄ ▗▄███▀ ]8;;  ▗█▛▐█▙ ▗▄█▀▗█▀ prime agent v0.9.5 ]8;;  ▗█▛ ▟██▙▄██▛ ▟▛ model — ]8;;  ▗▟▌ ▐███▛▘▗▄█▖ cwd ~/Projects/orca-readiness-fix ]8;; ▟███▄ ▄▄▟███▀ ]8;; ▜█▛▀▘ ▜█▛▀▘ ]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;  Collapsed mode (Ctrl+O to expand) ]8;; ]8;; >   ]8;; ]8;;← manage ]8;;[?2026l[?25l]0;prime-agent - orca-readiness-fix[?2026h  ▗█▛ ▟██▙▄██▛ ▟▛ model deepseek-v4.1-flash ]8;;← manage deepseek-v4.1-flash · 0 (0%)]8;;[?2026l[?25l[?2026h orca-readiness-fix $0.00]8;;[?2026l[?25l[?2026h[?2026l[?25l[>4;2m \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/prime-agent-0-9-5-turn.meta.json b/src/main/runtime/__fixtures__/prime-agent-0-9-5-turn.meta.json new file mode 100644 index 000000000000..844dae9fd609 --- /dev/null +++ b/src/main/runtime/__fixtures__/prime-agent-0-9-5-turn.meta.json @@ -0,0 +1,17 @@ +{ + "capturedAt": "2026-09-22T05:12:02.808Z", + "platform": "darwin", + "command": [ + "prime-agent", + "--daemon-socket", + "/tmp/prime-patch-capture-turn.sock", + "--provider", + "bifrost", + "--model", + "deepseek-v4.1-flash" + ], + "cols": 120, + "rows": 35, + "note": "Prime 0.9.5 bifrost deepseek-v4.1-flash harmless turn. Recorded by a contributor for PR #22154.", + "exitCode": 129 +} diff --git a/src/main/runtime/__fixtures__/prime-agent-0-9-5-turn.txt b/src/main/runtime/__fixtures__/prime-agent-0-9-5-turn.txt new file mode 100644 index 000000000000..9675de51b4b5 --- /dev/null +++ b/src/main/runtime/__fixtures__/prime-agent-0-9-5-turn.txt @@ -0,0 +1 @@ +[?2004h]10;?\]11;?\[?u[?25l[?1049h[?25l[?1002h[?1006h[?2026h orca-readiness-fix]8;;]8;;  ▗▄▄█▀ ]8;;  ███▄ ▗▄███▀ ]8;;  ▗█▛▐█▙ ▗▄█▀▗█▀ prime agent v0.9.5 ]8;;  ▗█▛ ▟██▙▄██▛ ▟▛ model — ]8;;  ▗▟▌ ▐███▛▘▗▄█▖ cwd ~/Projects/orca-readiness-fix ]8;; ▟███▄ ▄▄▟███▀ ]8;; ▜█▛▀▘ ▜█▛▀▘ ]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;  Collapsed mode (Ctrl+O to expand) ]8;; ]8;; >   ]8;; ]8;;← manage ]8;;[?2026l[?25l]0;prime-agent - orca-readiness-fix[?2026h  ▗█▛ ▟██▙▄██▛ ▟▛ model deepseek-v4.1-flash ]8;;← manage deepseek-v4.1-flash · 0 (0%)]8;;[?2026l[?25l[?2026h orca-readiness-fix $0.00]8;;[?2026l[?25l[?2026h[?2026l[?25l[>4;2m[?2026h > Do not call tools. Reply exactly READY.  ]8;;[?2026l[?25l[?2026h >   ]8;;[?2026l[?25l[?2026h ⠋ Waiting · 0s ]8;;[?2026l[?25l[?2026h]133;A ]8;; Do not call tools. Reply exactly READY. ]8;;]133;B]133;C ]8;; ⠋ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠙ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠹ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠸ Waiting · 0s ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠼ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠴ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠦ Waiting · 0s ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠧ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠇ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠏ Waiting · 0s ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠋ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠙ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠹ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠹ Waiting · 1s ]8;;[?2026l[?25l[?2026h ⠸ Waiting · 1s ]8;;[?2026l[?25l[?2026h ⠼ Waiting · 1s ]8;;[?2026l[?25l[?2026h ⠴ Waiting · 1s ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠦ Waiting · 1s ]8;;[?2026l[?25l[?2026h ⠧ Waiting · 1s ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠇ Waiting · 1s ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠏ Waiting · 1s ]8;;[?2026l[?25l[?2026h ⠋ Waiting · 1s ]8;;[?2026l[?25l[?2026h ⠙ Waiting · 1s ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠹ Waiting · 1s ]8;;[?2026l[?25l[?2026h ⠸ Waiting · 1s ]8;;[?2026l[?25l[?2026h ⠼ Waiting · 1s ]8;;[?2026l[?25l[?2026h ⠼ Waiting · 2s ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠴ Waiting · 2s ]8;;[?2026l[?25l[?2026h ⠦ Waiting · 2s ]8;;[?2026l[?25l[?2026h ⠧ Waiting · 2s ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠇ Waiting · 2s ]8;;[?2026l[?25l[?2026h ⠏ Waiting · 2s ]8;;[?2026l[?25l[?2026h]133;A]8;;]133;B]133;C READ ]8;; ⠏ Writing · 2s · ↓ 1 tokens ]8;;← manage deepseek-v4.1-flash · 1 (0%)]8;;[?2026l[?25l[?2026h]133;B]133;C READY ]8;; ⠋ Writing · 2s · ↓ 1 tokens ]8;;[?2026l[?25l[?2026h ⠋ Waiting · 2s · ↑ 3 tokens ]8;;← manage deepseek-v4.1-flash · 3 (0%)]8;;[?2026l[?25l[?2026h]8;;← manage deepseek-v4.1-flash · 8.4k (7%)]8;;[?2026l[?25l[?2026h[?2026l[?25l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/prime-agent-0-9-8-busy-streaming.meta.json b/src/main/runtime/__fixtures__/prime-agent-0-9-8-busy-streaming.meta.json new file mode 100644 index 000000000000..eef1372e20fe --- /dev/null +++ b/src/main/runtime/__fixtures__/prime-agent-0-9-8-busy-streaming.meta.json @@ -0,0 +1,15 @@ +{ + "capturedAt": "2026-09-30T19:19:14.695Z", + "platform": "darwin", + "command": [ + "prime-agent", + "--provider", + "openrouter", + "--model", + "inclusionai/ling-3.0-flash-sante:free" + ], + "cols": 120, + "rows": 40, + "note": "prime-agent 0.9.8, macOS, OpenRouter inclusionai/ling-3.0-flash-sante:free, isolated HOME, one turn, recording stopped while the turn was still running. Truncated 1449775 -> 20903 bytes, after the first Writing frame with the answer streaming.", + "exitCode": 129 +} diff --git a/src/main/runtime/__fixtures__/prime-agent-0-9-8-busy-streaming.txt b/src/main/runtime/__fixtures__/prime-agent-0-9-8-busy-streaming.txt new file mode 100644 index 000000000000..9a7be012bf1a --- /dev/null +++ b/src/main/runtime/__fixtures__/prime-agent-0-9-8-busy-streaming.txt @@ -0,0 +1 @@ +[?2004h]10;?\]11;?\[?u[?25l[?1049h[?25l[?1002h[?1006h[?2026h ws]8;;]8;;  ▗▄▄█▀ ]8;;  ███▄ ▗▄███▀ ]8;;  ▗█▛▐█▙ ▗▄█▀▗█▀ prime agent v0.9.8 ]8;;  ▗█▛ ▟██▙▄██▛ ▟▛ model — ]8;;  ▗▟▌ ▐███▛▘▗▄█▖ cwd ~/ws ]8;; ▟███▄ ▄▄▟███▀ ]8;; ▜█▛▀▘ ▜█▛▀▘ ]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;  Details mode (Ctrl+O to expand) ]8;; ]8;; >   ]8;; ]8;;← manage ]8;;[?2026l[?25l]0;prime-agent - ws[?2026h ws $0.00]8;;  ▗█▛ ▟██▙▄██▛ ▟▛ model inclusionai/ling-3.0-flash-sante:free ]8;;[Skill warning] ]8;; auto (project) /Users/xxxxxx/.agents/skills/orchestration/SKILL.md ]8;; description exceeds 1024 characters (1038) ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 0 (0%)]8;;[?2026l[?25l[?2026h[?2026l[?25l[>4;2m[?2026h > Without using any tools, write the numbers 1 to 400 in chat, one per line.  ]8;;[?2026l[?25l[?2026h >   ]8;;[?2026l[?25l[?2026h]133;A ]8;; Without using any tools, write the numbers 1 to 400 in chat, one per line. ]8;;]133;B]133;C ]8;; ⠋ Waiting · 0s ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠙ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠹ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠸ Waiting · 0s ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠼ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠴ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠦ Waiting · 0s ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠧ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠇ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠏ Waiting · 0s ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠋ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠙ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠹ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠹ Waiting · 1s ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h[?2026l[?25l[?2026h]133;A]8;;]133;B]133;C The user wants me to write numbers 1 to ]8;; ⠸ Thinking · 1s · ↓ 10 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 10 (0%)]8;;[?2026l[?25l[?2026h]133;B]133;C The user wants me to write numbers 1 to 400 in chat ]8;; ⠸ Thinking · 1s · ↓ 13 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 13 (0%)]8;;[?2026l[?25l[?2026h]133;B]133;C The user wants me to write numbers 1 to 400 in chat, one per line ]8;; ⠸ Thinking · 1s · ↓ 16 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 16 (0%)]8;;[?2026l[?25l[?2026h]133;B]133;C The user wants me to write numbers 1 to 400 in chat, one per line, without ]8;; ⠸ Thinking · 1s · ↓ 19 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 19 (0%)]8;;[?2026l[?25l[?2026h]133;B]133;C The user wants me to write numbers 1 to 400 in chat, one per line, without using any tools. I'll just type them out ]8;; ⠼ Thinking · 1s · ↓ 29 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 29 (0%)]8;;[?2026l[?25l[?2026h The user wants me to write numbers 1 to 400 in chat, one per line, without using any tools. I'll just type them out ]8;; directly. ]8;;]8;; 1 ]8;;]133;B]133;C 2 ]8;; ⠼ Writing · 1s · ↓ 32 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 32 (0%)]8;;[?2026l[?25l[?2026h 2 ]8;; 3 ]8;;]133;B]133;C 4 ]8;; ⠼ Writing · 1s · ↓ 33 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 33 (0%)]8;;[?2026l[?25l[?2026h 4 ]8;; 5 ]8;; 6 ]8;;]133;B]133;C 7 ]8;; ⠼ Writing · 1s · ↓ 35 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 35 (0%)]8;;[?2026l[?25l[?2026h 7 ]8;; 8 ]8;;]133;B]133;C 9 ]8;; ⠴ Writing · 1s · ↓ 36 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 36 (0%)]8;;[?2026l[?25l[?2026h  ▗▄▄█▀ ]8;;  ███▄ ▗▄███▀ ]8;;  ▗█▛▐█▙ ▗▄█▀▗█▀ prime agent v0.9.8 ]8;;  ▗█▛ ▟██▙▄██▛ ▟▛ model inclusionai/ling-3.0-flash-sante:free ]8;;  ▗▟▌ ▐███▛▘▗▄█▖ cwd ~/ws ]8;; ▟███▄ ▄▄▟███▀ ]8;; ▜█▛▀▘ ▜█▛▀▘ ]8;;]8;;[Skill warning] ]8;; auto (project) /Users/xxxxxx/.agents/skills/orchestration/SKILL.md ]8;; description exceeds 1024 characters (1038) ]8;;]8;;]133;A ]8;; Without using any tools, write the numbers 1 to 400 in chat, one per line. ]8;;]133;B]133;C ]8;;]133;A]8;; The user wants me to write numbers 1 to 400 in chat, one per line, without using any tools. I'll just type them out ]8;; directly. ]8;;]8;; 1 ]8;; 2 ]8;; 3 ]8;; 4 ]8;; 5 ]8;; 6 ]8;; 7 ]8;; 8 ]8;; 9 ]8;; 10 ]8;;]133;B]133;C 11 ]8;;]8;; ⠴ Writing · 1s · ↓ 37 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 37 (0%)]8;;[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/prime-agent-0-9-8-draft.meta.json b/src/main/runtime/__fixtures__/prime-agent-0-9-8-draft.meta.json new file mode 100644 index 000000000000..7b2a15f94b5a --- /dev/null +++ b/src/main/runtime/__fixtures__/prime-agent-0-9-8-draft.meta.json @@ -0,0 +1,15 @@ +{ + "capturedAt": "2026-09-30T19:52:14.786Z", + "platform": "darwin", + "command": [ + "prime-agent", + "--provider", + "openrouter", + "--model", + "inclusionai/ling-3.0-flash-sante:free" + ], + "cols": 120, + "rows": 40, + "note": "prime-agent 0.9.8, macOS, OpenRouter inclusionai/ling-3.0-flash-sante:free, isolated HOME, text typed into the composer and not submitted", + "exitCode": 129 +} diff --git a/src/main/runtime/__fixtures__/prime-agent-0-9-8-draft.txt b/src/main/runtime/__fixtures__/prime-agent-0-9-8-draft.txt new file mode 100644 index 000000000000..e2fcee8d486d --- /dev/null +++ b/src/main/runtime/__fixtures__/prime-agent-0-9-8-draft.txt @@ -0,0 +1 @@ +[?2004h]10;?\]11;?\[?u[?25l[?1049h[?25l[?1002h[?1006h[?2026h ws]8;;]8;;  ▗▄▄█▀ ]8;;  ███▄ ▗▄███▀ ]8;;  ▗█▛▐█▙ ▗▄█▀▗█▀ prime agent v0.9.8 ]8;;  ▗█▛ ▟██▙▄██▛ ▟▛ model — ]8;;  ▗▟▌ ▐███▛▘▗▄█▖ cwd ~/ws ]8;; ▟███▄ ▄▄▟███▀ ]8;; ▜█▛▀▘ ▜█▛▀▘ ]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;  Details mode (Ctrl+O to expand) ]8;; ]8;; >   ]8;; ]8;;← manage ]8;;[?2026l[?25l[?2026h  ▗█▛ ▟██▙▄██▛ ▟▛ model inclusionai/ling-3.0-flash-sante:free ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 0 (0%)]8;;[?2026l[?25l]0;prime-agent - ws[?2026h ws $0.00]8;;[Skill warning] ]8;; auto (project) /Users/xxxxxx/.agents/skills/orchestration/SKILL.md ]8;; description exceeds 1024 characters (1038) ]8;;[?2026l[?25l[?2026h[?2026l[?25l[>4;2m[?2026h > summarize the repo  ]8;;[?2026l[?25l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/prime-agent-0-9-8-ready-80x24.meta.json b/src/main/runtime/__fixtures__/prime-agent-0-9-8-ready-80x24.meta.json new file mode 100644 index 000000000000..1f6c8a24b951 --- /dev/null +++ b/src/main/runtime/__fixtures__/prime-agent-0-9-8-ready-80x24.meta.json @@ -0,0 +1,15 @@ +{ + "capturedAt": "2026-09-30T19:18:39.983Z", + "platform": "darwin", + "command": [ + "prime-agent", + "--provider", + "openrouter", + "--model", + "inclusionai/ling-3.0-flash-sante:free" + ], + "cols": 80, + "rows": 24, + "note": "prime-agent 0.9.8, macOS, OpenRouter inclusionai/ling-3.0-flash-sante:free, isolated HOME, later launch at 80x24, settled empty composer", + "exitCode": 129 +} diff --git a/src/main/runtime/__fixtures__/prime-agent-0-9-8-ready-80x24.txt b/src/main/runtime/__fixtures__/prime-agent-0-9-8-ready-80x24.txt new file mode 100644 index 000000000000..0e9c2b0751da --- /dev/null +++ b/src/main/runtime/__fixtures__/prime-agent-0-9-8-ready-80x24.txt @@ -0,0 +1 @@ +[?2004h]10;?\]11;?\[?u[?25l[?1049h[?25l[?1002h[?1006h[?2026h ws]8;;]8;;  ▗▄▄█▀ ]8;;  ███▄ ▗▄███▀ ]8;;  ▗█▛▐█▙ ▗▄█▀▗█▀ prime agent v0.9.8 ]8;;  ▗█▛ ▟██▙▄██▛ ▟▛ model — ]8;;  ▗▟▌ ▐███▛▘▗▄█▖ cwd ~/ws ]8;; ▟███▄ ▄▄▟███▀ ]8;; ▜█▛▀▘ ▜█▛▀▘ ]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;  Details mode (Ctrl+O to expand) ]8;; ]8;; >   ]8;; ]8;;← manage ]8;;[?2026l[?25l]0;prime-agent - ws[?2026h ws $0.00]8;;  ▗█▛ ▟██▙▄██▛ ▟▛ model inclusionai/ling-3.0-flash-sante:free ]8;;[Skill warning] ]8;; auto (project) /Users/xxxxxx/.agents/skills/orchestration/SKILL.md ]8;; description exceeds 1024 characters (1038) ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 0 (0%)]8;;[?2026l[?25l[?2026h[?2026l[?25l[>4;2m \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/prime-agent-0-9-8-ready-after-question.meta.json b/src/main/runtime/__fixtures__/prime-agent-0-9-8-ready-after-question.meta.json new file mode 100644 index 000000000000..a5ed34223128 --- /dev/null +++ b/src/main/runtime/__fixtures__/prime-agent-0-9-8-ready-after-question.meta.json @@ -0,0 +1,15 @@ +{ + "capturedAt": "2026-09-30T19:18:15.679Z", + "platform": "darwin", + "command": [ + "prime-agent", + "--provider", + "openrouter", + "--model", + "inclusionai/ling-3.0-flash-sante:free" + ], + "cols": 120, + "rows": 40, + "note": "prime-agent 0.9.8, macOS, OpenRouter inclusionai/ling-3.0-flash-sante:free, isolated HOME, first launch: trace-sharing question answered Not now, then settled", + "exitCode": 129 +} diff --git a/src/main/runtime/__fixtures__/prime-agent-0-9-8-ready-after-question.txt b/src/main/runtime/__fixtures__/prime-agent-0-9-8-ready-after-question.txt new file mode 100644 index 000000000000..a498613b4e54 --- /dev/null +++ b/src/main/runtime/__fixtures__/prime-agent-0-9-8-ready-after-question.txt @@ -0,0 +1 @@ +[?2004h]10;?\]11;?\[?u[?25l[?1049h[?25l[?1002h[?1006h[?2026h ws]8;;]8;;  ▗▄▄█▀ ]8;;  ███▄ ▗▄███▀ ]8;;  ▗█▛▐█▙ ▗▄█▀▗█▀ prime agent v0.9.8 ]8;;  ▗█▛ ▟██▙▄██▛ ▟▛ model — ]8;;  ▗▟▌ ▐███▛▘▗▄█▖ cwd ~/ws ]8;; ▟███▄ ▄▄▟███▀ ]8;; ▜█▛▀▘ ▜█▛▀▘ ]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;  Details mode (Ctrl+O to expand) ]8;; ]8;; >   ]8;; ]8;;← manage ]8;;[?2026l[?25l]0;prime-agent - ws[?2026h ws $0.00]8;;  ▗█▛ ▟██▙▄██▛ ▟▛ model inclusionai/ling-3.0-flash-sante:free ]8;;[Skill warning] ]8;; auto (project) /Users/xxxxxx/.agents/skills/orchestration/SKILL.md ]8;; description exceeds 1024 characters (1038) ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 0 (0%)]8;;[?2026l[?25l[?25l[?2026h]8;; ]8;;]8;;]8;;· ▗▄▄█▀ · · · · · ]8;;]8;;]8;;◆ ·███▄ ····▗▄███▀ · ·· ╎ ·· ·• ·· ┃· ·· ·• · ·· · ╎ ·· ·• ·· ┃ ·· ·]8;;]8;;]8;; ·· ···▗█▛▐█▙·· ▗▄█▀▗█▀ ·· ◆·· ╎·· •·· ◆· ╎·· ·· ··· ┃·· •·· ·· ╎◆· ·· ··· ╎·◆ •·· ·· ╎·· ·· ··· ┃·· •·· ···╎·· ]8;;]8;;]8;; ·· ·▗█▛ ▟██▙▄██▛·▟▛·· ·· ·· ╎ ·· ·· ··╎·· ·· ·· ╎ ·· ·· ··┃·· ·· ·· ╎ ·· ·· ···╎·· ·· ·· ╎ ·· ·◆ ··┃·· ]8;;]8;;]8;;•· · ▗▟▌·▐███▛▘▗▄█▖· ·•·─ ╎ ··· • ·╎·· · ·•· ╎ ·◆· • ·┃·─ · ·•·· ╎ ─··· •· ·╎· · ·•◆ ╎ ··· ·• ·┃· · ─·]8;;]8;;]8;;• ·· ▟███▄··▄▄▟███▀· ·• ··┃ ·· ·· ·╎ ·· ·• ··╎ ·· ·· · ·╎ ·· ·• ··┃ ·· ·· ·╎ ·· ··• ··╎ ·· ·· ·╎ ·· ·]8;;]8;;]8;; ·▜█▛▀▘ ▜█▛▀▘ · · · · • · · · · ··· · • · · · · · · • · · · · · • · · · ]8;;]8;;]8;; ]8;;]8;;]8;; Welcome to PRIME Agent ]8;;]8;;]8;; ]8;;]8;;]8;; Share agent traces with Prime Intellect? ]8;;]8;;]8;; ]8;;]8;;]8;; Trace sharing helps us train better open-source ]8;;]8;;]8;; models and improve the open agent ecosystem for ]8;;]8;;]8;; everyone. ]8;;]8;;]8;; ]8;;]8;;]8;; > Share  ]8;;]8;;]8;;  Not now  ]8;;]8;;]8;; ]8;;]8;;]8;; You can change this anytime with /traces. ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h]8;; ·▗▄▄█▀· · · · · ]8;;]8;;]8;;· ··███▄ ·· ▗▄███▀ ·· ╎ ·◆ •╎ ··· ·┃ ··· •╎ ·· ·╎ ·· •╎ ·· ·┃ ·· •]8;;]8;;]8;; ······▗█▛▐█▙·· ▗▄█▀▗█▀ · ◆··· ╎· •··· ·· ·╎·· · ···· ┃· •··· · ·◆·· · ···· ╎◆ •··· · ·╎·· · ·····┃· •··· · ·╎·· ]8;;]8;;]8;; · ··▗█▛ ▟██▙▄██▛·▟▛·· · ··· ╎ ····· · ╎·· ··· ··· ╎ · ··· · ┃··· · ··· ╎ · ··· · ╎·· · ··· ╎ · ·◆· · ┃·· ]8;;]8;;]8;;·· · ▗▟▌·▐███▛▘▗▄█▖ •··─·╎ ··· • ─·╎· · · •·· ·╎ ··· • ·┃· · •·· ·╎ ─··· • ╌·╎·─· •◆· ·╎ ··· • ·┃· ·─ •]8;;]8;;]8;;· · ▟███▄· ▄▄▟███▀ ·•· · ┃ ··· · ··╎ · ·•· · ╎··◆· · ··╎ · ·•· · ┃ ··· ·· ··╎ · ·•· · ╎ ··· · ··╎ · ·•]8;;]8;;]8;; · ▜█▛▀▘ •▜█▛▀▘ · · · · • · · · · · • ·· · · · · · ╌ •·· · · · · • · · · ]8;;]8;;[?2026l[?25l[>4;2m[?2026h]8;; ╌ ▗▄▄█▀ · ╌ · ]8;;]8;;]8;; ··███▄ ·· ▗▄███▀ ··╎ · ·◆ ┃ •· ··╎ ·· ╎ •· ··╎ · ·· ┃ •· ··╎ ·· ╎ •·]8;;]8;;]8;;·· ··· ▗█▛▐█▙· ▗▄█▀▗█▀ ·· ··· ╎· ··· ··· ·╎·· ·· ··· ┃· ··· ···◆╎· ·· ··· ◆· ··· ··· ·╎· ·· ··· ┃· ·◆· ·· ·╎· ]8;;]8;;]8;; ·· ··▗█▛·▟██▙▄██▛·▟▛· ·◆ ╎· ·• ·· ╎·· ·· ···┃· ·• ·· ╎·· ··· ·· ╎· ·• ·· ╎·· ·· ·· ┃· ·• ·· ╎·· ·· ]8;;]8;;]8;;· · ▗▟▌ ▐███▛▘▗▄█▖· •·· ·╎ ··· · ··╎ · •·· ─·╎ ··· · ··┃ ╌· •·· ·╎ ··· · ··╎ ─· •◆· ·╎ ··· · ··┃ ·─ •·]8;;]8;;]8;; ·· ▟███▄• ▄▄▟███▀ •· ·· ·· ·• ╎ ·· ·· ·•·╎·· ◆· ·• ┃ ·· ·· •·╎·· ·· ·• ╎ ·· ·· •·╎·· ·· ·• ┃ ·· ·· •·]8;;]8;;]8;; · ▜█▛▀▘ ·▜█▛▀▘ ··· · · · · · · · · · · · · · · · ·· · · · · · · · · · · ]8;;]8;;[?2026l[?25l[?2026h]8;; · ·▗▄▄█▀ · ╌ · ]8;;]8;;]8;; ··███▄ ··· ▗▄███▀ ··╎ ◆· ·┃ ·· ··╎ ···╌ ╎ ·· ·◆╎ ╌ ·· ┃ ·· ··╎ ··· · ╎ ··]8;;]8;;]8;;· ···· ▗█▛▐█▙· ▗▄█▀▗█▀ ······ ╎ ····╌ · ··╎• ·· ···· ┃ ···· · ◆·╎• · ···· ·╎ ···· · ··╎• · ···· ┃ ·◆·· · ··╎• ]8;;]8;;]8;; · ···▗█▛•▟██▙▄██▛ ▟▛• ◆ ·╎· · • ··· ╎· ··• ···┃· • ··· ╎· ··• · · ·╎· • ··· ╎· ··• · ·┃· • ···· ╎· ··• ]8;;]8;;]8;;· · ▗▟▌·▐███▛▘▗▄█▖ ··· · ╎ ··· · ─ ··╎ · ··· · ╎ ··· · ···┃ ·· ··· · ╎ ··· · ··╎ · ··· · ╎ ··· · ··┃ · ··]8;;]8;;]8;; · ·▟███▄ ▄▄▟███▀ •·· · ··· • ╎··· · · •··╎· ◆·· • ┃····· •··╎· ··· • ╎··· · •◆·╎· ··· • ┃··· · •··]8;;]8;;]8;; ·· ·▜█▛▀▘· ▜█▛▀▘ ·╌· · · · · · · · · · · · · · · · ·· · · · · · · · · ·· · ]8;;]8;;[?2026l[?25l[?2026h]8;; · ·· ▗▄▄█▀ · · · · · ]8;;]8;;]8;; ·· ███▄ ·· ▗▄███▀ ·· ╎ ◆· ·· ┃·· ·· ╎ ·· · ╎·· ·◆ ╎ ·· ·┃·· ·· ╎ ·· ╎·· ]8;;]8;;]8;;· ··· ▗█▛▐█▙· ·▗▄█▀▗█▀·· ··╎ ·• ···· ·┃ ··• ·· ··╎ ·• ··· ·╎ ··• ·· ··╎ ·• ··· ·┃···• ·· ··╎ ·• ◆·· ·╎ ··• ◆]8;;]8;;]8;;·· ·· ▗█▛·▟██▙▄██▛ ▟▛ ◆· ·╎ •· ·· ╎· ·• ·· ·┃ •· ·· ╎◆ ·• ·· ·╎ •· ·· ╎···• ·· ·┃· •· ·· ╎· ·• ]8;;]8;;]8;; ·· ·▗▟▌ ▐███▛▘▗▄█▖ ··· · ··· · ╎··· · ···┃ · ··· · ╎··· · ····╎ · ··· · ╎···─·· ···┃ · ─··· · ╎··· · ···]8;;]8;;]8;;·· ▟███▄ ▄▄▟███▀ ·· ·· ···•· ╎·· ··· ··· ╎· ·· •· ┃·· ·· ·· ╎· ·· •· ╎·· ·· ◆· ╎· ·· •· ┃·· ·· ·· ]8;;]8;;]8;; · ·▜█▛▀▘· ▜█▛▀▘·· •· · · · · · ··• · ◆ · ·· · • · · · · · • · · · · · • ]8;;]8;;[?2026l[?25l[?2026h]8;; · · ▗▄▄█▀ · · · · · ]8;;]8;;]8;; ·· ███▄···· ▗▄███▀ ·· ╎ ·· · ┃·· ·· ╎ ··· ╎·· ◆· ╎ ·· ┃·· ·· ╎ ·· ╎·◆ ]8;;]8;;]8;; ···· ▗█▛▐█▙ ·▗▄█▀▗█▀· ···╎ • ···◆ ·┃··•· ·╌···╎ • ···· ·╎··•·· · ···╎ • ···· ·┃··•· · ···╎ ·• ◆···· ·╎··•· ·]8;;]8;;]8;;· ··· ▗█▛ ▟██▙▄██▛·▟▛ · ··╎ ·· ··· ╎ ·•· · ··┃ · ··· ◆ ·•· · · ··╎ ····· ╎ ·•·· · ··┃ · ··· ╎ ·•· ]8;;]8;;]8;; · ·▗▟▌·▐███▛▘▗▄█▖ ··◆ · ··• ·─·╎··· ·· ···┃· ··• · ╎··· · ···╎·─ ··• · ╎··· · ···┃· ─··• · ╎···─· ···]8;;]8;;]8;;· ·▟███▄ ·▄▄▟███▀ ····· ··· · ╎·· · ··· ╎ ··· · ┃·· · ··· ╎ ··· · ╎·· · ·◆·· ╎ ··· · ┃·· · ··· ]8;;]8;;]8;; · · ▜█▛▀▘ ·▜█▛▀▘· • · · · · · • · ◆· · · ·· · • · · · · · • · · · · · • ]8;;]8;;[?2026l[?25l[?2026h]8;; · ╌ ▗▄▄█▀ · · · · ]8;;]8;;]8;; ·· ███▄·· ▗▄███▀ ┃· ·· ╌╎ ··· ╎· ·· ╎ · ·· ◆· ·· ·╎ ·· ╎· ·· ╎ ·◆ ]8;;]8;;]8;; ··· ·•▗█▛▐█▙ ··▗▄█▀▗█▀· ···╎ •· ··◆ ··┃·•· ·· ···╎ •· ··· ··╎·•· ·· ···╎ •· ··· ··┃·•· ·· ···╎ •· ··· ··╎·•· ··]8;;]8;;]8;;···· ▗█▛ ▟██▙▄██▛•▟▛ ·· ·· ···· ··┃ ·· •· ·╎ ·· · ·· ··╎· ◆· •· ·╎··· ·· ··┃ ·· •· ··╎ ·· ·◆ ··╎ ·· •· ·]8;;]8;;]8;; · ··▗▟▌·▐███▛▘▗▄█▖··◆ · ··• ·─ ╎·· · ··· ┃·· ··• · ╎·· · ··· ╎· ··• · ·╎·· ·· ··· ┃·─ ··• · ╎·· ─· ··· ]8;;]8;;]8;;· ·▟███▄╌ ▄▄▟███▀ ·· ·· ·· ·╎ ·· ·· ┃···· · ·· ·╎ ·· ·· · ╎· ·· ·· ·╎ ··· ·· ┃· ·· ·· ·╎ ·· ·· ╎· ·]8;;]8;;]8;; · · ▜█▛▀▘ ·▜█▛▀▘· · · · · · · · · · · · · · · · ·· · · · · ◆ · · · · · · ]8;;]8;;[?2026l[?25l[?2026h]8;; · ▗▄▄█▀ · · · · · · ]8;;]8;;]8;; ·• ███▄·· · ▗▄███▀ ┃• ··· ╎ ·· · ╎• ·· ╎ ···· ┃• ·· ╎ ·· ╎• ·· ╎ ◆·· ]8;;]8;;]8;;····· ·▗█▛▐█▙ · ▗▄█▀▗█▀ ····╎ ····◆· · ┃•·· · ····╎ · ···· · ╎•·· ·····◆╎ · ···· ·· ┃•·· · ····╎ · ···· · ╎•·· · ]8;;]8;;]8;;···· ▗█▛·▟██▙▄██▛·▟▛ · ···· ·· ···┃ · •·· ·╎···· · ···╎ · •·· ·╎··· · ···┃ · •·· ·╎··· ◆ ···╎ · •·· ·]8;;]8;;]8;;· ·•▗▟▌ ▐███▛▘▗▄█▖····· ·•· ·· ╎·· · ··· ┃ · ·•· · ─ ╎·◆ · ···╌╎ ·•· · ╎·· · ╌··· ┃ ─ ·•· · ╎·· · ··· ]8;;]8;;]8;; ··▟███▄ ·▄▄▟███▀··◆ · ··• ·╎ ····· ··┃· · ··• ·╎ ··· · ·╎· · ··• ·╎ ··· ·· ·┃· · ··• ·╎ ····· ·╎· ·]8;;]8;;]8;;· · ▜█▛▀▘· ▜█▛▀▘ · · · · · · ·· · · · · · · · · ··· ╌ · ◆ · · · ·· · · · ]8;;]8;;[?2026l[?25l[?2026h]8;; ·· ▗▄▄█▀ ·· · · ╌ ╌ · ]8;;]8;;]8;; ·• ███▄· ▗▄███▀ ·• ··· ╎ ·· · ·• ·· ╎ ·· ·• ·· ╎╌ ·· · ·• ·· ╎ ◆· ]8;;]8;;]8;;···╌ ··▗█▛▐█▙·· ▗▄█▀▗█▀ ··· ·· ··· ╎·· •·· ·· ╎··· ·· ··· ╎·· •·· ·· ┃·◆ ·· ··· ╎·· •·· ··· ╎·· ·· ··· ╎·· •·· ·· ]8;;]8;;]8;; ·· ▗█▛·▟██▙▄██▛·▟▛··· ··╌ ···◆· ┃ ·· ·· ··╎·· ·· ·· ╎ ·· ·· ··╎··· ·· ·· ┃ ·· ·· ··╎·· ◆· ·· ╎ ·· ·· ··]8;;]8;;]8;;· ·•·▗▟▌ ▐███▛▘▗▄█▖·· · ·•· ·┃ ··· • ·╎· · ·•· ╎ ─··◆ • ·╎·─ · ·•· ┃ ··· • ·╎· · ·•· ╎ ···─ • ·╎· ]8;;]8;;]8;; · ·•▟███▄ ·▄▄▟███▀·◆ ·· ·• ··╎ ······ ·┃ ·· ·• ··╎ ·· ·· ·╎··· ·• ··╎ ·· ·· · ·┃ ·· ·• ··╎ ·· ·· ·╎ ··]8;;]8;;]8;;· · ▜█▛▀▘··▜█▛▀▘ · · · · · · •·· ╌ ◆ · · · •·· · · · · · • · · · · · · • · ]8;;]8;;[?2026l[?25l[?2026h]8;; ·· ▗▄▄█▀ ╌· ╌ · · · ]8;;]8;;]8;; •· ·███▄· ▗▄███▀ •┃ ·· ╎ ·· · •╎ ·· ╎ ··╌ · •┃· ·· ╎ ·· •╎ ·· ╎ ·· ]8;;]8;;]8;;··· · ▗█▛▐█▙· •▗▄█▀▗█▀···· · ···· ╎· •··· · ·╎·· ······· ╎· •··· · ·┃◆· · ···· ╎· •··· · ·╎·· · ···· ╎· •··◆ · ·]8;;]8;;]8;;··· ▗█▛·▟██▙▄██▛·▟▛ · ··· · ◆·· ┃ · ··· · ╎·· ····· ╎ · ··· · ╎·· · · ··· ┃ · ···· · ╎·· · ··· ╎ · ··· · ]8;;]8;;]8;; ·•··▗▟▌ ▐███▛▘▗▄█▖·· · •··─·┃ ··· • ·╎· · •·· ·╎ ··· • ·╎·─·· •·· ·┃ ─··· • ·╎· ·─ •·◆ ·╎· ··· • ·╎· ·]8;;]8;;]8;; ·•·▟███▄··▄▄▟███▀·· · ·•· · ╎ ··· · ··┃ · · ·•· · ╎ ··◆·· ··╎·· ·•· · ╎ ··· · ··┃ · ·•· · ╎ ··· · ··╎ · ]8;;]8;;]8;; · ▜█▛▀▘ ·▜█▛▀▘· · · · · · •··· · ◆ · · · • · · · · · ·• · · · · · • · ·]8;;]8;;[?2026l[?25l[?2026h]8;; · ╌ ▗▄▄█▀ ·· · · · · · ]8;;]8;;]8;; ·•· ·· ███▄ ·▗▄███▀ •· ··╎ ·· ╎╌•· · ··┃ ·· · ╎ •· ··╎ ·· ╎ •· · ··┃ ·· ]8;;]8;;]8;;·· ·· ▗█▛▐█▙· ·▗▄█▀▗█▀··· ······ ╎· ···· ·· ·╎· ·· ··· ╎· ··· ·· ·┃· ·· ··· ╎· ··· ····╎· ·· ··· ╎· ··◆ ·· ·]8;;]8;;]8;;·· ·▗█▛·▟██▙▄██▛ ▟▛·· ·· ·· ╎· ·• ·· ╎·· ·· ·· ╎· ·• ·· ┃···◆· ·· ╎·· ·• ·· ╎·· ·· ·· ╎· ·• ·· ┃·· ]8;;]8;;]8;; ╌•·· ▗▟▌ ▐███▛▘▗▄█▖· · •···─◆┃ ··· · ··╎· · •··· ·╎ ··· · ··╎ · ·•·· ·┃ ··· · ··╎ ─· •·◆ ·╎ ··· · ···╎ ·]8;;]8;;]8;; •· ▟███▄··▄▄▟███▀···· •·┃·· ·· ·• ╎ ·· ·◆ ·•·╎·· ·◆ ·• ╎ ·· ·· •·┃·· ·· ·• ╎ ·· ·· •·╎·· ·· ·• ╎ ·· ·· ]8;;]8;;]8;; · ▜█▛▀▘· ▜█▛▀▘· · · · · ··· ·· · · · · ·· ·· · · · · ◆ · · · · · · · ·]8;;]8;;[?2026l[?25l[?2026h]8;; · ▗▄▄█▀ · · · · · · · ]8;;]8;;]8;; ··╌ ███▄ ·▗▄███▀ ·· · ···╎ ·· ╎ ·· ··┃ ·· ·╎ ·· · ··╎ ·· ╎ ·· ··┃ ·· ]8;;]8;;]8;;·• · ·▗█▛▐█▙ ··▗▄█▀▗█▀··• · ···· ╎····· · ··╎• · ···· ╎ ···· · ··┃•· · ···· ╎ ···· · ··╎• · ···· ╎ ··◆· · ··]8;;]8;;]8;;·• ·▗█▛·▟██▙▄██▛ ▟▛· ··• · ·╎· • ··· ╎· ··• · · ·╎· •···· ┃· ◆·• · ·╎· • ··· ╎· ··• · ·╎· • ··· ┃· ·]8;;]8;;]8;;· ··◆ ▗▟▌ ▐███▛▘▗▄█▖· · ··· ◆ ┃ ··· · ··╎ · ····· ╎ ◆·· · ··╎ · ··· · ┃ ··· · ··╎ ·· ··· · ╎ ··· · ··╎ · ]8;;]8;;]8;; •···▟███▄··▄▄▟███▀· · •··┃· ··· • ╎··· ◆ · •··╎· ··· • ╎··· · · •··┃· ···· • ╎··· · •·◆╎· ··· • ╎··· · ]8;;]8;;]8;;· ▜█▛▀▘ ▜█▛▀▘ · · · · · · ··· · · · · · · ··· · · · ◆ · · · · · · · · ]8;;]8;;[?2026l[?25l[?2026h]8;; · ·▗▄▄█▀ · · · · · ]8;;]8;;]8;; ··· ███▄ ··▗▄███▀·· ··· ╎ ·· ╎··· ··· ┃ ·· ╎·· ·· ╎ ·· · ╎·· ··· ┃· ·· ]8;;]8;;]8;;• ·· ·▗█▛▐█▙ ··▗▄█▀▗█▀·•· ·· ··╎ ·• ··· ·╎ ··• ·· ··┃ ·• ··· ·╎ ··• ·· ··╎ ·• ··· ·╎ ··• ·· ··┃ ·• ··· ·╎ ··]8;;]8;;]8;;• ··▗█▛ ▟██▙▄██▛ ▟▛·╌·• ·◆ ·╎ •· ·· · ╎· ·• ·· ·╎ · •· ·· ┃· ·• ····╎ •· ·· ╎· ·• ·· ·╎ •· ◆· ┃· ·]8;;]8;;]8;; ··◆ ▗▟▌·▐███▛▘▗▄█▖ · ···╎ · ··· · ╎··· · ···╎ · ◆·· ·· ┃··· ◆ ···╎ · ─··· · ╎···─ · ···╎ · ··· · ┃··· · ]8;;]8;;]8;; ····▟███▄· ▄▄▟███▀ ·· ·· ┃· ··· •· ·╎·· ◆· ···╎· ·· •· ╎·· ·· ··· ┃· ·· •◆ ╎·· ·· ·◆ ╎· ···•· ╎·· ·· ]8;;]8;;]8;;•· ▜█▛▀▘ ▜█▛▀▘ · • · · ·· · · • · · · · · · • · · · ·╌ · · • · · · · · ]8;;]8;;[?2026l[?25l[?2026h]8;; · · ▗▄▄█▀ ╌· · · · ·]8;;]8;;]8;; ·· ███▄ ··▗▄███▀·· · ·· ╎ ·· ╎·· · ·· ┃ ·· ╎·· ·· ╎ · ·· · ╎·· ·· ┃ ·· ]8;;]8;;]8;;· · ··▗█▛▐█▙···▗▄█▀▗█▀•· ·· ···╎ • ···· ·╎··•· · ···┃ •····· ·╎··•· ·····╎ • ···· ·╎··•· · ···┃ • ···· ··╎··•]8;;]8;;]8;;· · ▗█▛ ▟██▙▄██▛ ▟▛··•· ◆ ··╎· · ··· ╎ ·•· · ··◆ ·· ··· ┃ ·•· · ··╎ · ··· ╎ ·•· · ··╎ · ◆·· ┃ ·•]8;;]8;;]8;;─·◆· ·▗▟▌·▐███▛▘▗▄█▖ · ···╎· ··• · ╎··◆ · ···╎· · ··• · ┃··· ◆ ···╎·─ ··• · ╎···─· ╌◆··╎·· ─··• · ┃··· ·─ ]8;;]8;;]8;;··· ·▟███▄· ▄▄▟███▀ · ··· ┃ ···· · ╎·· · ··· ╎ ··· · ╎·· · ··· ┃ ··· ◆ ╎·· ·· ····╎ ··· · ╎·· · ]8;;]8;;]8;; ▜█▛▀▘· ▜█▛▀▘ · • · · ··· · · •· · · · · · • · · · · · • ·· · · · · •]8;;]8;;[?2026l[?25l[?2026h]8;; ╌· · ▗▄▄█▀ · · · ╌ · ╌ · ]8;;]8;;]8;;·· ███▄ ·· ▗▄███▀· ╌· ╎· ·· ┃ ·· · ╎·· ··· ╎ ·· ╎· ·· ┃ ╌ ·· ╎· ··· ╎ ]8;;]8;;]8;; ··· ··▗█▛▐█▙·◆·▗▄█▀▗█▀· ··◆ ···╎ •· ··· ··╎·•·· ·· ···┃ •· ··· ··╎·•· ·· ···╎ •· ··· ··╎·•· ··· ···┃ •· ··· ··╎·•·]8;;]8;;]8;; ··· ▗█▛ ▟██▙▄██▛ ▟▛ •· ·╎ ·· ·· ··╎ ·· •· ·┃ ·◆ ·· ··╎ ·◆ •· ·╎ ··· ·· ··╎ ·· •· ·┃ ·· ·· ··╎ ··· •·]8;;]8;;]8;;·◆· ·▗▟▌·▐███▛▘▗▄█▖ · ··· ╎· ··• ·─ ╎·◆ · ··· ╎· ··• · ┃·· · ····╎·─ ··• · ╎·· · ◆·· ╎· ··• ·◆ ┃·· ─· ]8;;]8;;]8;;·· ··▟███▄ ·▄▄▟███▀·· ·· ···╌ ·· ·╎ ·· ·· ╎· ··· ····┃ ·· ·· ╎· ·· ·· ◆╎ ·· ·· · ╎· ·· ·· ·┃ ·· ·· ]8;;]8;;]8;; ▜█▛▀▘ ▜█▛▀▘ · · · ··· ╌ · · · · · ╌ · · · · ··· · · · · · · · · · · · · ·]8;;]8;;[?2026l[?25l[?2026h]8;; · · ▗▄▄█▀ · · ╌ ·· · · ]8;;]8;;]8;;·· ███▄ ·· ▗▄███▀· · ╎• · ·· ┃ ·· ╎• ·· ╎ ··╌ ╎• ·· ┃ ··· ╎• ·· ╎ ]8;;]8;;]8;; · ···▗█▛▐█▙◆··▗▄█▀▗█▀·· ◆ ····╎ · ···· · ╎•·· · ····┃·· ···· · ╎•·· · ····╎ ······ · ╎•·· · ····┃ · ···· · ╎•··]8;;]8;;]8;; · ·▗█▛ ▟██▙▄██▛ ▟▛•·· ·╎··· · ···╎ · •·· ·┃·◆· ·····╎ ◆ •··· ·╎··· · · ···╎ · •·◆ ·┃··· · ···╎ · •··]8;;]8;;]8;;··· · ▗▟▌•▐███▛▘▗▄█▖· ···· ╎ ·•· · ─ ╎◆· · ··· ╎ ·•· · ┃·· · ··· ╎ ·•◆·· ╎·· · ··· ╎ ─ ·•· ◆ ┃·· · ]8;;]8;;]8;;◆· · ▟███▄ ·▄▄▟███▀· ··· ·· ··• ·╎ ··· · ·╎··· ··• ·┃ ··· · ·╎··· ··• ·╎· ··· · ·╎· · ··• ·┃ ··· · ··]8;;]8;;]8;; ·▜█▛▀▘ ▜█▛▀▘ · · · · · · ·· · · · · · ··· · ·· · · · · ·· · · · · · · · ]8;;]8;;[?2026l[?25l[?2026h]8;; · ▗▄▄█▀ · · · · · · ]8;;]8;;]8;;· ·███▄· ··· ▗▄███▀ · ·• ·· ┃ ·· ╌·• ··· ╎ ·· · ·• ·· ┃ ·· ·• ··· ╎ ··]8;;]8;;]8;; ·· ···▗█▛▐█▙·· ▗▄█▀▗█▀ ·◆· ╎·· ·· ··· ┃·· •·· ·· ╎◆· ·· ··· ╎·◆ •··· ·· ╎·· ·· ··· ┃·· •·· ·· ╎·· ·· ··· ╎·· •·· ]8;;]8;;]8;; ·· ·▗█▛ ▟██▙▄██▛·▟▛·· ··╎·· ·· ·· ╎ ·· ·· ··┃·· ·· ···╎ ·· ·· ··╎·· ·· ·· ╎ ·· ·◆ ··┃·· ·◆ ·· ╎ ·· ·· ]8;;]8;;]8;;·· · ▗▟▌·▐███▛▘▗▄█▖• ···· · ·•· ╎ ·◆· • ·┃·─ · ·•·· ╎ ─··· • ·╎· · ··•◆ ╎ ··· • ·┃· · ─·•· ╎ ···─ • ·]8;;]8;;]8;;· ·· ▟███▄··▄▄▟███▀· ·· ·· ·• ··╎ ·· ··· ·╎ ·· ··• ··┃ ·· ·· ·╎ ·· · ·• ··╎ ·· ·· ·╎ ·· ·• ··┃ ··· ·· ·]8;;]8;;]8;; ··▜█▛▀▘ ▜█▛▀▘ • · · · · · · • · ·· · ·· · • · · · · · · • · · · · · • · ]8;;]8;;[?2026l[?25l[?2026h]8;; · ▗▄▄█▀ · · · · · · ]8;;]8;;]8;;· •███▄ ·· ▗▄███▀ · •╎ ·· · ┃ ·· •╎ ·· ╎ ·· · •╎ ·· · ┃ ·· •╎ ·· ╎ ·]8;;]8;;]8;; · ····▗█▛▐█▙·· ▗▄█▀▗█▀ · ·╎·· · ···· ┃· •··· · ·◆·· · ···· ╎◆·•··· · ·╎··· · ···· ┃· •··· · ·╎·· · ···· ╎· •··· ]8;;]8;;]8;; · ··▗█▛ ▟██▙▄██▛·▟▛·· · ╎·· · · ··· ╎ ····· · ┃·· · ··· ╎ · ··· · ╎·· ····· ╎ · ·◆· · ┃·· ◆ ··· ╎ · ··· ]8;;]8;;]8;;·· · ▗▟▌·▐███▛▘▗▄█▖ ╌─··· · · •·· ·╎ ··· • ·┃· · •·· ·╎ ─··· • ·╎·─· •◆· ·╎ ··· • ·┃· ·─ •·· ·╎ ···─• ·]8;;]8;;]8;;· ·· ▟███▄· ▄▄▟███▀ ··· · ·•· · ╎ ·◆· · ··╎ · ·•· · ┃ ··· · ···╎ · · ·•· · ╎ ··· · ··╎ · ·•· · ┃ ··· · ··]8;;]8;;]8;;· ·· ▜█▛▀▘ ·▜█▛▀▘ • · · · · · · • · ··· · · • · · · · · · · ·• · · · · · • · ]8;;]8;;[?2026l[?25l[?2026h]8;; · ▗▄▄█▀ ·╌ ·· · · · ]8;;]8;;]8;; •·███▄ ·· ▗▄███▀· ┃ •· ··╎ ·· ╎ •·· ··╎ · ··· ┃·•· ··╎ ·· · ╎ •· ··╎ ··]8;;]8;;]8;;·· ··· ▗█▛▐█▙· ▗▄█▀▗█▀ ·· ·╎· ·· ··· ┃· ··· ··· ◆╎·· ·· ··· ◆· ··· ·· ·╎· ·· ··· ┃· ·◆· ·· ·╎· ·◆ ··· ╎· ··· ]8;;]8;;]8;; ·· ··▗█▛·▟██▙▄██▛•▟▛· · ·· ·· ·· ┃· ·• ·· ╎·· ·· ·· ╎· · ·• ·· ╎·· ·· ·· ┃· ·• ·· ╎·· ·· ·· ╎· ·• ·· ]8;;]8;;]8;;· ·· ▗▟▌ ▐███▛▘▗▄█▖· ··· · •·· ─·╎···· · ··┃ · •·· ·╎ ··· · ··╎ ─· •◆· ·╎ ··· · ··┃ ·─ •··· ·╎ ··· · ··]8;;]8;;]8;; ·· ▟███▄· ▄▄▟███▀ ·· ·· · •·╎·· ◆· ·• ┃ ·· ·· •·╎·· ·· ·• ╎ ·· ·· ·•·╎·· ·· ·• ┃ ····· •·╎·· ·· ·• ╎ ··]8;;]8;;]8;; · · ▜█▛▀▘ ·▜█▛▀▘ ··· · ·· · · · · · · · · · · · · · · · · ·╌ · · · ·· · · ]8;;]8;;[?2026l[?25l[?2026h]8;;╌ · ▗▄▄█▀ · · · · ╌ · ]8;;]8;;]8;; ··███▄ ·· ▗▄███▀ ┃ ·· ··╎ · ·· · ╎ ·· · ·◆╎ ·· ┃ ·· ··╎ ·· · ╎ ·· ··╎ ··]8;;]8;;]8;;· ···• ▗█▛▐█▙· ▗▄█▀▗█▀ · ··╎• · ···· ┃ ···· · ◆·╎• · ···· ╎ ···· · ··╎• ······ ┃··◆·· · ··╎• ◆ ···· ╎ ···· ]8;;]8;;]8;; · ··•▗█▛◆▟██▙▄██▛ ▟▛· · ··• · ·┃· • ··· ╎· ··• · ·╎· ·• ··· ╎· ··• · ·┃· • ··· ╎· ··• ·· ·╎· • ··· ]8;;]8;;]8;;· · ▗▟▌ ▐███▛▘▗▄█▖─ ··· · ··· · ╎ ··· · ···┃ · ··· · ╎ ··· · ··╎ · ···╌· ╎ ··· · ··┃ · ··· · ╎ ··· · ·◆]8;;]8;;]8;; · ▟███▄ ▄▄▟███▀ ····· •··╎· ◆·· • ┃··· · •··╎· ··· • ╎··· · •◆·╎· ····• ┃··· · •··╎· ··· • ╎···]8;;]8;;]8;;· · ·▜█▛▀▘· ▜█▛▀▘ · · · ·· · · · · · · · · · · · · · · · · · · · · · · · · ·]8;;]8;;[?2026l[?25l[?2026h]8;; · ▗▄▄█▀· · · ╌· · ]8;;]8;;]8;; ·· ███▄ ·· ▗▄███▀ · ┃·· ·· ╎ ·· ╎·· · ·◆ ╎ ·· · ┃·· ·· ╎ ·· ╎·· ·· ╎ ·· ]8;;]8;;]8;;· ··• ▗█▛▐█▙ ·▗▄█▀▗█▀·· ··• ··· ··╎ ·• ··· ·╎ ··• ·· ··╎· ·• ··· ·┃ ··•· ·· ··╎ ·• ◆·· ·╎ ··• ◆· ··╎ ·• ··· ·]8;;]8;;]8;;·· ·• ▗█▛·▟██▙▄██▛ ▟▛ ·· ·• ·· ·┃ •·╌·· ╎◆ ·• ·· ·╎ •· ·· ╎· ·• ·· ·┃· •· ·· ·╎· ·• ·· ·╎ •· ·· ]8;;]8;;]8;; · ·▗▟▌ ▐███▛▘▗▄█▖─··· · ···┃ · ··· · ╎··· ·· ···╎ · ··· · ╎···─ · ···┃ · ─··· · ╎··· · ···╎ · ··· · ╎·◆·]8;;]8;;]8;;·· ▟███▄ ▄▄▟███▀ ·· ·· ·· ╎· ·· •· ┃····· ·· ╎·· ·· •· ╎·· ·· ◆· ╎·· ·· •· ┃·· ·· ·· ╎· ·· •· ·╎·· ]8;;]8;;]8;; · •▜█▛▀▘· ▜█▛▀▘ · · ··• · ◆ · · · ·· • ╌ · · · · ··• · · · · · • · · · · ]8;;]8;;[?2026l[?25l[?2026h]8;; · ▗▄▄█▀ · ╌ · · · · ]8;;]8;;]8;; · ·· ███▄ ·· ▗▄███▀ ┃·· ·· ╎ ·· ·╎·· ╌ ◆· ╎ ·· ┃··╌ ·· ╎ ·· ·╎·◆ · ·· ╎ ·· ]8;;]8;;]8;; ··•· ▗█▛▐█▙ •▗▄█▀▗█▀· ··•· · ···╎ • ···· ·╎··•· · ···╎ ·• ···· ·┃··•· · ···╎ • ◆··· ·╎··•· · ···╎ •····· ·]8;;]8;;]8;;· ·•· ▗█▛ ▟██▙▄██▛·▟▛ · ·•· · ··┃ · ···· ◆ ·•·· · ··╎ · ··· ╎ ·•· · ··┃ · · ··· ╎ ·•· · ··╎ · ··· ]8;;]8;;]8;; · ·▗▟▌·▐███▛▘▗▄█▖ ··· ·· ···┃· ··• · ╎··· · ···╎·─· ··• · ╎··· · ···┃· ·─··• · ╎···─· ···╎· ··• · ╎···]8;;]8;;]8;;· ·▟███▄ ▄▄▟███▀ ··· · ··· ╎ ··· ·· ┃·· · ··· ╎ ···· · ╎·· · · ◆···╎ ··· · ┃·· · ··· ╎ ···· · ╎◆· ]8;;]8;;]8;; · • ▜█▛▀▘ ·▜█▛▀▘· · ··• · ◆ · · · • · ··· · · · · •· ·· · · · ·· • · · · · ]8;;]8;;[?2026l[?25l[?2026h]8;; ▗▄▄█▀ · · · · · · ]8;;]8;;]8;; ·· ███▄·· ▗▄███▀ ·· ╎· ·· ╎ ·· ◆· ·· ╎ ·· ·· ╎· ·· ╎ ·◆ ┃· ·· ]8;;]8;;]8;; ·•· ·▗█▛▐█▙ •·▗▄█▀▗█▀· ·•· ·· ···╎ •· ··· ··╎·•· ··· ···╎ •· ··· ··┃·•· ·· ···╎ •· ··· ··╎·•·· ·· ···╎ •· ··· ··]8;;]8;;]8;;· •· ▗█▛ ▟██▙▄██▛·▟▛ ·· •· ·╎··· ·· ··╎ ·◆· •· ·╎ ·· ·· ··┃ ·· •· ·╎ ·· ·◆ ··╎ ·· •· ·╎ ·· ·· ··┃ ·]8;;]8;;]8;; · ··▗▟▌·▐███▛▘▗▄█▖··· · ··· ┃· ··• · ╎·· · ··· ╎· ··•· · ·╎·· · ··· ┃·─ ··• · ╎·· ─· ··· ╎· ··• · ╎·· ]8;;]8;;]8;;· ·▟███▄ ▄▄▟███▀ ·· ·· ┃· ··· ·· ·╎ ·· ·· ╎· ·· ·· ·╎ · ·· ·· ┃· ·· ·· ·╎ ·· ·· ╎· ·· ·· ·╎ ◆· ·]8;;]8;;]8;; · · ▜█▛▀▘ ·▜█▛▀▘· · · · · · · · · · ·· · · · · ◆ ·· · · · · · ·· · · · · ]8;;]8;;[?2026l[?25l[?2026h]8;; · ▗▄▄█▀ ·· · · ]8;;]8;;]8;; ·· ███▄·• ▗▄███▀ ·· ╎• ·· ╎ ·· · ┃• ·· ╎ ·· · ╎• ·· ╎ ◆· · ┃• ·· ]8;;]8;;]8;;·•·· ·▗█▛▐█▙ · ▗▄█▀▗█▀ ·•·· · ····╎ · ···· · ╎•·· · ···◆╎ · ···· · ┃•·· · ····╎ · ···· ··╎•·· · ····╎ · ···· · ]8;;]8;;]8;; •·· ▗█▛·▟██▙▄██▛·▟▛ ··•·· ·╎··· ·· ···╎ · •·· ·╎··· · ···┃ · •·· ·╎··· ◆····╎ · •·· ·╎··· · ···┃ ·]8;;]8;;]8;;· ··▗▟▌ ▐███▛▘▗▄█▖··· · ··· ┃ ·•· · ─·╎·◆ · ··· ╎ ·•· · ╎·· ·· ··· ┃ ─ ·•· · ╎·· · ··· ╎ ·•· · ╎·· ]8;;]8;;]8;; ··▟███▄ ·▄▄▟███▀··· · · ·┃· · ··• ·╎ ··· · ·╎· · ··• ·╎ ··· · ·┃· · · ··• ·╎ ··· · ·╎· · ··• ·╎ ◆·· ·]8;;]8;;]8;;· · ▜█▛▀▘· ▜█▛▀▘ ·· ·· · · · · · · · ··· · · · ◆ · · · · · · · · · · · ]8;;]8;;[?2026l[?25l[?2026h]8;; · ▗▄▄█▀ · · · · · ]8;;]8;;]8;; ·· ███▄• ··▗▄███▀ ·· · ·• ·· ╎ ·· ·•· ·· ╎ ·· ··• ·· ╎ ◆· ·• ·· ]8;;]8;;]8;;•·· ··▗█▛▐█▙·· ▗▄█▀▗█▀ •·· ·· ╎·· ·· ··· ╎·· •·· ·· ┃·◆ ·· ··· ╎·· •·· ··· ╎··· ·· ··· ╎·· •·· ·· ┃·· ·· ··· ╎·· ]8;;]8;;]8;; ·· · ▗█▛·▟██▙▄██▛·▟▛ ·· ·· ··╎·· ╌·· ·· ╎ ·· ·· ··╎·· ·· ·· ┃ ·· ·· ··╎·· ◆· ·· ╎ ·· ·· ··╎·· ·· ·· ┃ ··]8;;]8;;]8;;• ─···▗▟▌ ▐███▛▘▗▄█▖·· • ·╎· · ·•· ╎╌─··◆ • ·╎·─ · ·•· ┃· ··· • ·╎· ·· ·•· ╎ ···─ • ·╎· · ─·•· ┃ ···· ]8;;]8;;]8;; ·◆▟███▄ ·▄▄▟███▀·· ·· ·┃ ·· ·• ··╎ ·· ·· ·╎ ·· ·•···╎ ·· ·· ·┃ ·· ··•···╎ ·· ·· ·╎ ·· ·• ··╎ ·· ··]8;;]8;;]8;;• · ▜█▛▀▘· ▜█▛▀▘·· · • · · ◆ · ·· · • · · · · · • · · · · · · • · · · · ◆ · ]8;;]8;;[?2026l[?25l[?2026h]8;; · ·▗▄▄█▀ · · · · · ]8;;]8;;]8;; ·· ·███▄· · ▗▄███▀ ·· •╎ ·· ╎ ·· •┃ · ·· ╎ ·· •╎ ·· ╎ ·· •┃ · ·· ]8;;]8;;]8;;··· · ▗█▛▐█▙· ·▗▄█▀▗█▀•··· · ·╎·· · ···· ╎· •··· · ·┃◆· · ···· ╎· •··· · ·╎·· ······ ╎· •··◆ · ·┃·· · ···· ╎··•]8;;]8;;]8;;··· ▗█▛·▟██▙▄██▛·▟▛ · ··· · ╎···· · ··· ╎ · ··· · ╎·· · ··· ┃·· ··· · ╎·· ·· ··· ╎ · ··· · ╎·· · ··· ┃ · ]8;;]8;;]8;; ─◆··▗▟▌ ▐███▛▘▗▄█▖·· • ·╎· · •·· ·╎ ··· • ·╎·─· •·· ·┃ ─··· • ·╎· ·─ •·◆╌·╎· ··· • ·╎· · ─•·· ·┃ ···─•]8;;]8;;]8;; ···▟███▄·•▄▄▟███▀·· · ··┃ · ·•· · ╎ ··◆ · ··╎ · ·•· · ╎ ··· · ··┃ ·· ·•· · ╎ ··· · ··╎ · ·•· · ╎ ··· · ]8;;]8;;]8;; · ▜█▛▀▘ ▜█▛▀▘· · • · ◆ · · · · •·· · · ╌ · · • · · · · · • · · · · ◆ •]8;;]8;;[?2026l[?25l[?2026h]8;; ╌ ▗▄▄█▀ · · ╌ · ]8;;]8;;]8;; ·· ███▄ · ·▗▄███▀ ·· ╎ •· · ··┃ ·· ╎ •· · ··╎ ·· ╎ •· · ··┃ ·· ╎ •· ··╎ ]8;;]8;;]8;;·· ·· ▗█▛▐█▙· ·▗▄█▀▗█▀··· ·· ·╎· ·· ··· ╎····· ·· ·┃· ··· ···· ╎· ··· ·· ·╎· ·· ··· ╎· ··◆ ·· ·┃·· ·· ··· ╎· ·]8;;]8;;]8;;·· ·▗█▛·▟██▙▄██▛ ▟▛·• ·· ╎·· ··· ·· ╎· ·• ·· ┃·· ◆· ·· ╎· ·• ·· ╎·· ·· ·· ╎·· ·• ·· ┃·· ·· ·· ╎· ·• ]8;;]8;;]8;;─ ◆·· ▗▟▌ ▐███▛▘▗▄█▖· · ··╎· · •·· ·╎ ··· ╌· ··╎ · •·· ··┃ ··· · ··╎ ─· •·◆ ·╎ ··· · ··╎ ·─ •·· ·┃ ··· ─·]8;;]8;;]8;; ·· ▟███▄•·▄▄▟███▀· ·• ·· ·◆ •·╎·· ·◆ ·• ╎ ·· ·· •·┃··· ·· ·•·╎ ·· ·· •·╎·· ·· ·• ╎ ·· ·· · •·┃·· ·· ·• ]8;;]8;;]8;; · ▜█▛▀▘ ▜█▛▀▘· · · · · · · · · · · · · · · ◆ · ··· · · · · · · · · ·]8;;]8;;[?2026l[?25l[?2026h]8;; · ▗▄▄█▀ ╌ · · · ]8;;]8;;]8;; ·· ███▄ ╌ ·▗▄███▀ ·· ╎ ·· ··┃ ·· ╎ ·· · ╌ ··╎ ·· ╎ ··╌ ··┃ ·· ╎ ·· ··╎ ]8;;]8;;]8;;·· · ·▗█▛▐█▙ ··▗▄█▀▗█▀··· · ··╎• · ···· ╎ ···· · ··┃• · ···· ·╎ ···· · ··╎• · ···· ╎ ··◆· · ··┃• · ···· ╎ ··]8;;]8;;]8;;·◆ ··▗█▛•▟██▙▄██▛ ▟▛• ··· ╎· ··•· · ·╎· • ···· ┃· ◆·• · ·╎· • ··· ╎· ··• · ·╎· · • ··· ┃· ··• · ·╎· • ·]8;;]8;;]8;;─ ··· ▗▟▌·▐███▛▘▗▄█▖· · ··╎ · ··· · ╎ ◆·· ·· ··╎ · ··· · ┃ ··· · ··╎ · ··· · ╎ ··· · ··╎ ··─ ··· · ┃ ··· · ]8;;]8;;]8;; ··· ▟███▄··▄▄▟███▀· • ··· ◆· •··╎· ··· • ╎··· · •··┃· ····· • ╎··· · •·◆╎· ··· • ╎··· · •··┃· ··· • ]8;;]8;;]8;;· ▜█▛▀▘ ▜█▛▀▘ ╌ · · · · · · · · · · · · · ◆ · ·· ·· · · · · · · · · · · ]8;;]8;;[?2026l[?25l[?2026h]8;; ··· ▗▄▄█▀ · · · · ]8;;]8;;]8;; ·· ███▄ · ··▗▄███▀·· ╎·· · ·· ┃ ·· ╎·· ·· ╎ ·· ╎·· · ·· ┃ ·· ╎·· ·· ╎ ]8;;]8;;]8;;· ·· ·▗█▛▐█▙ ··▗▄█▀▗█▀·· ·╎ ··• ·····┃ ·• ··· ·╎ ··• ·· ··╎ ·• ··· ·╎ ··• ··· ··┃· ·• ··· ·╎ ··• ·· ··╎ ·• ··]8;;]8;;]8;;◆ ···▗█▛ ▟██▙▄██▛ ▟▛· ··· ╎· ·• ·· ·╎ •· ·· ·┃· ·• ·· ·╎ •· ·· ╎· ·• ·· ·╎ •· ◆· ┃· ·• ·· ·╎ ·•· ·]8;;]8;;]8;; ··· ▗▟▌·▐███▛▘▗▄█▖ · ─··· · ···╎·· ◆·· · ┃··· ◆ ···╎ · ─···· · ╎···─ · ···╎ · ··· · ┃··· · ···╎ ·· ···─ · ]8;;]8;;]8;; ·· ·▟███▄· ▄▄▟███▀ •·· ·· ◆· ·· ╎· ·· •· ╎·· ·· ·· ┃· ·· •◆ ╎·· ·· ·◆ ╎· ·· •· ╎·· ·· ·· ┃· ·· •· ]8;;]8;;]8;;· ▜█▛▀▘ ▜█▛▀▘·· · · · • · · · · · • ·· · · · · • · · · · ·· · • · ◆ · ··]8;;]8;;[?2026l[?25l[?2026h]8;; · ▗▄▄█▀ · · · · · ]8;;]8;;]8;; ·· ███▄· ···▗▄███▀·· ╎·· ··· ┃· ·· ╎·· ·· ╎· ·· ╎·· · ·· ┃ ·· ╎·· ·· ╎· ]8;;]8;;]8;;· · ··▗█▛▐█▙···▗▄█▀▗█▀·· ·╎··•· ·····┃ • ···· ·╎··•· · ···╎ • ···· ·╎··•· · ···┃ • ···· ·╎··•· · ···╎ • ···]8;;]8;;]8;;· ╌ ··▗█▛ ▟██▙▄██▛ ▟▛ ··· ╎ ·•· · ··◆ · ··· ┃ ·•· · ··╎ · ··· ╎ ·•· · ··╎ ··◆·· ┃ ·•· · · ··╎ ····]8;;]8;;]8;; ··· ·▗▟▌·▐███▛▘▗▄█▖ · ─··◆ · ···╎· ··• · ┃··· ◆ ···╎·─ ··• · ╎···─· ◆··╎· ─··• · ┃··· ·─ ···╎· ··• · ]8;;]8;;]8;;··· ·▟███▄··▄▄▟███▀ · ··· · ··· ╎ ··· ·╌ ╎·· · ··· ┃ ··· ◆ ╎·· · ··· ╎ · ··· · ╎·· · ··· ┃ ··· ·· ]8;;]8;;]8;; ▜█▛▀▘ ▜█▛▀▘ · · · · • ··· · · · • ··· · · · • · · · · · · • ◆ · · ·]8;;]8;;[?2026l[?25l[?2026h]8;; · ╌ ▗▄▄█▀ · · · · ]8;;]8;;]8;;·· ███▄ ·· ▗▄███▀· ┃ ·· ╌╎· · ·· ╎ ·· ╎·· ·· ┃ ·· ·╎· · ·· ·╎ ·· ╎· ]8;;]8;;]8;; ····•▗█▛▐█▙···▗▄█▀▗█▀· ··╎·•· ·· ···┃ •· ··· ···╎·•· ·· ···╎ •· ··· ··╎·•· ·· ···┃ •· ··· ··╎·•· ··· ···╎ •· ···]8;;]8;;]8;; · ·· ▗█▛ ▟██▙▄██▛ ▟▛ ·· ···•· ·┃ ·◆ ·· ··╎ ·◆·•· ·╎ ·· ·· ··╎· ·· •· ·┃ ·· ·· ··╎· ·· •· ·╎ ·· ·· ··]8;;]8;;]8;;··· ·▗▟▌·▐███▛▘▗▄█▖ ·─ ··◆ · ··· ╎· ··• · ┃·· · ··· ╎·─ ··• ·· ╎·· · ◆·· ╎· ··• ·◆ ┃·· ─· ··· ╎· ··• · ]8;;]8;;]8;;·· ··▟███▄╌·▄▄▟███▀·· ·· ·· ╎· ·· ··· ·┃ · ·· ·· ╎· ·· ···◆╎ ·· ·· ╎· ·· ··· ·┃ ·· ·· ╎· ·◆· ·· ·╎ ]8;;]8;;]8;; ▜█▛▀▘ ▜█▛▀▘ · · · · ·· · · · · · · · · · · · · · · · · · · · · · ·]8;;]8;;[?2026l[?25l[?2026h]8;; · ▗▄▄█▀ · · · · · · ]8;;]8;;]8;;·· ███▄ ·• ▗▄███▀· ┃ ·· · ╎• ··· ╎ ·· ╎• · ··· ┃ ·· ╎• ·· ╎ ·· ╎• ]8;;]8;;]8;; · ·•·▗█▛▐█▙···▗▄█▀▗█▀· · ╎•·· ╌· ····┃ · ···· · ╎•·· · ····╎ · ···· · ╎•·· · ····┃ ······ · ╎•·· · ····╎ · ····]8;;]8;;]8;;· · •▗█▛ ▟██▙▄██▛ ▟▛··· ··•·· ·┃·◆· · ···╎ ◆ •·· ·╎··· ·· ···╎ ·· •·◆ ·┃··· · ···╎ · •·· ·╎··· · ···]8;;]8;;]8;;··· · ▗▟▌·▐███▛▘▗▄█▖· ─ ·◆· · ··· ╎ ·•· · ┃·· · ··· ╎ ·•◆ · ╌╎·· · ··· ╎ ─ ·•·╌◆ ┃·· · ··· ╎ ·•· · ]8;;]8;;]8;;·· · ▟███▄ ·▄▄▟███▀· ··· · ·╎· · ···• ·┃ ··· · ·╎· · ··• ·╎ ··· · ·╎· · ··• ·┃ ··· · · ·╎· ◆ ··• ·╎ ·]8;;]8;;]8;; ·▜█▛▀▘ ▜█▛▀▘ · · · · · · ·· · · · · · · ╌ ·· · · · · · · · · · · · · · ]8;;]8;;[?2026l[?25l[?2026h]8;; · ▗▄▄█▀ ·· · · ╌ ╌ · ]8;;]8;;]8;;[38;2;124;111;175m· ·███▄ ·• ▗▄███▀ ┃ ·· · ·• ·· ╎· ·· ·• · ·· ┃ ·· ·•╌ ·· ╎· ·· ·• ·]8;;]8;;]8;; ··╌•··▗█▛▐█▙·· ▗▄█▀▗█▀ ·· •·· ··· ╎◆· ·· ··· ╎·◆·•·· ·· ╎·· ·· ··· ┃·· •·· ·· ╎·· ··· ··· ╎·· •·· ·· ╎·· ·· ··· ]8;;]8;;]8;; ·· ·▗█▛ ▟██▙▄██▛·▟▛·· ·· ·· ··┃·· ·· ·· ╎ ·· ·· ···╎·· ·· ·· ╎ ·· ·◆ ··┃·· ·◆ ·· ╎ ·· ·· ··╎·· ·· ·· ]8;;]8;;]8;;·· ·•·▗▟▌·▐███▛▘▗▄█▖· ·◆· • ·┃·─ · ··•· ╎ ─··· ╌• ·╎· · ·•◆ ╎ ··· • ·┃· · ─·•· ╎ ···─ • ·╎·· · ·•· ╎ ◆]8;;]8;;]8;;· ·· ▟███▄··▄▄▟███▀· ·· ·· ·╎ ·· · ·• ··┃ ·· ·· ·╎ ··· ·• ··╎ ·· ·· ·╎ ·· ·•···┃ ·· ·· ·╎ ◆· ·• ··╎ ·]8;;]8;;]8;; •▜█▛▀▘ ▜█▛▀▘ · · • · · · ·· · · • · · · · · · • · ···· · · · • · · · · · ]8;;]8;;[?2026l[?25l[?2026h]8;; ·· ▗▄▄█▀ ╌ ╌ · · · ]8;;]8;;]8;;· ·███▄ •· ▗▄███▀· ┃ ··· ·· •╎ ·· ╎ ·· •╎ ╌ ·· ┃ ··· •╎ ·· ╎ ·· •╎ ·]8;;]8;;]8;; ··•···▗█▛▐█▙·· ▗▄█▀▗█▀ · •···· · ·◆·· · ···· ╎◆ •··· · ·╎·· · ···· ┃· •··· · ·╎·· · ···· ╎· •··· · ·╎·· · ···· ]8;;]8;;]8;; · ··▗█▛ ▟██▙▄██▛·▟▛·· · ··· · ┃·· · ··· ╎ · ···· · ╎·· · ··· ╎ · ·◆· · ┃·· ◆ ··· ╎ · ··· · ╎·· · ··◆ ]8;;]8;;]8;;···• ▗▟▌·▐███▛▘▗▄█▖─ ··· • ·┃· · •·· ·╎ ─··· •· ·╎·─· •◆· ·╎ ··· • ·┃· ·─ •·· ·╎· ···─• ·╎◆ · ─•····╎ ·]8;;]8;;]8;;· · ▟███▄· ▄▄▟███▀· ·◆· · ··╎ ·╌· ·•· · ┃ ··· · ··╎ · ·•· · ╎···· · ··╎ · ·•· · ┃ ··· · ··╎ · ·•· · ╎ ··]8;;]8;;]8;; • ▜█▛▀▘ ·▜█▛▀▘· · · • · · · · · · • · · · · · • · · ·· · · · • · · · · ·· ]8;;]8;;[?2026l[?25l[?2026h]8;; ·╌ ▗▄▄█▀ · · · · · · · ]8;;]8;;]8;; · ··◆███▄ •· ▗▄███▀ ·· ╌ ╎ •· ··╎╌ ·· ┃ •· ··╎ ·· ╎ •· · ··╎ ·· ┃ •· ··]8;;]8;;]8;;·· ··· ▗█▛▐█▙· ▗▄█▀▗█▀ ·· ···· ·· ◆╎· ·· ··· ◆· ··· ·· ·╎· ·· ··· ┃· ·◆· ·· ·╎· ·◆ ···· ╎· ··· ·· ·╎· ··· ··· ]8;;]8;;]8;; ·• ··▗█▛·▟██▙▄██▛·▟▛· ·• ·· ╎·· ·· ·· ╎· ·• ·· ╎·· ·· ·· ┃· ·• ·· ╎·· ·· ·· ╎· ··• ·· ╎····· ·· ┃◆· ]8;;]8;;]8;;·╌ · ▗▟▌ ▐███▛▘▗▄█▖ ··· · ···┃ · •·· ·╎ ··· · ···╎ ─· •◆· ·╎ ···· · ··┃ ·─ •·· ·╎ ··· · ··◆ · •·· ·╎ ··]8;;]8;;]8;; ·• ▟███▄· ▄▄▟███▀ ◆· ·• ┃ ·· ·· •·╎·· ·· ·• ╎ ·· ·· •·╎·· ·· ·• ┃ ·· ·· •·╎·· ·· ·• ╎ ·· ·· •·╎·· ··]8;;]8;;]8;; · ▜█▛▀▘··▜█▛▀▘ · · ·· · · · ╌ · ·· · · · · ·· · · · · · · · · · · · · ··· ]8;;]8;;[?2026l[?25l[?2026h]8;; · · ▗▄▄█▀ · · · · · · · ]8;;]8;;]8;; ╌ ◆·███▄ ·· ▗▄███▀ ···· ╎ ·· ·◆╎ · ·· ┃ ·· ··╎ ··· ╎··· · ··╎ ·· ┃ ·· ··]8;;]8;;]8;;· ···· ▗█▛▐█▙• ▗▄█▀▗█▀ · ···· · ◆·╎• · ···· ╎ ···· · ··╎• · ···· ┃ ·◆·· · ··╎• ◆ ···· ·╎ ···· · ··╎• · ···· ]8;;]8;;]8;; • ···▗█▛·▟██▙▄██▛ ▟▛· ··• ··· ╎· ··• · ·╎· • ··· ╎· ··• · ·┃· • ··· ╎· ··• · · ·╎· •···· ╎· ··• · ·◆· ]8;;]8;;]8;;· · ─ ▗▟▌ ▐███▛▘▗▄█▖ ··· ··· ··┃ · ··· · ╎ ··· · ··╎ · · ··· · ╎ ··· · ··┃ · ··· · ╎ ··· · ·◆╎ · ─ ··· · ╎ ··]8;;]8;;]8;; • ·▟███▄ ▄▄▟███▀ ◆·· • ┃··· · •··╎· ··· •╌ ╎··· · •◆·╎· ··· • ┃··· · •··╎· · ··· • ╎··· ·· •··╎· ···]8;;]8;;]8;; · ·▜█▛▀▘· ▜█▛▀▘ · · · · ··· · · · ·· · · ·· · ··· · · · · · · · · · · · · · ]8;;]8;;[?2026l[?25l[?2026h]8;; · · ▗▄▄█▀ · · · · · · ]8;;]8;;]8;; · ◆· ███▄ ·· ▗▄███▀ ·· · ╎·· ·◆ ╎ · ·· · ┃·· ·· ╎ ·· ╎·· ··· ╎ ·· ┃·· ·· ]8;;]8;;]8;;• ··· ▗█▛▐█▙ ·▗▄█▀▗█▀·• ··· ·╎···• ·· ··╎ ·• ··· ··┃ ··• ·· ··╎ ·• ◆·· ·╎ ··• ◆· ··╎ ·• ··· ·┃ ··• ·· ·◆╎ ·]8;;]8;;]8;;•· ·· ▗█▛·▟██▙▄██▛ ▟▛ ╌ •· ·· ╎◆ ·• ·· ·╎ •· ·· ╎···• ·· ·┃ •· ·· · ╎· ·• ·· ·╎ •· ·· ╎· ·• ·· ·┃ ]8;;]8;;]8;;· · ─·▗▟▌ ▐███▛▘▗▄█▖ ··· ··╎··· ·· ···╎ · ··· · ╎···─ · ···┃ · ─··· ··╎··· · ···╎ · ··· · ╎·◆· · ─···┃ · ···]8;;]8;;]8;;•· · ▟███▄ ▄▄▟███▀ ·· •· ┃·· ·· ·· ╎· ·· •· ·╎·· ·· ◆· ╎· ·· •· ┃·· ·· ·· ╎· ··· •·· ╎·· ·· ·· ╎· ·· ]8;;]8;;]8;; · ·▜█▛▀▘· ▜█▛▀▘ · ◆ · · · •· · · · · · · • · · · ·· · • ╌· · · · · • · · ]8;;]8;;[?2026l[?25l[?2026h]8;; · ▗▄▄█▀ · ╌· · · · · ]8;;]8;;]8;; · ·· ███▄ ·· ▗▄███▀ ··· · ╎··· ◆· ╎ ··· ┃·· ·· ╎ ·· ╎·◆· ·· ╎ ·· ┃·· ·· ]8;;]8;;]8;; ···◆ ▗█▛▐█▙ ·▗▄█▀▗█▀• ···· ·╎··•· · ···╎ • ···· ·┃··•· · ···╎ • ◆··· ·╎··•· · ···╎ •····· ·┃··•· · ·◆·╎ •]8;;]8;;]8;;· ··· ▗█▛ ▟██▙▄██▛·▟▛· · ··· ◆ ·•· · ··╎ ·· ··· ╎ ·•· · ··┃ · ··· ·╎ ·•· · ··╎ · ··· ╎ ·•· · ··┃ ]8;;]8;;]8;; ·─ ·▗▟▌·▐███▛▘▗▄█▖ ··• ·╌ ╎··· · ···╎·─ ··• · ╎··· · ···┃· ─··• · ╎···─· ···╎· ··•╌· ╎··· ·─ ···┃· ··•]8;;]8;;]8;;· · ·▟███▄ ▄▄▟███▀ ··· · ┃···· ··· ╎ ··· · ╎·· ·· ◆·· ╎ ··· · ┃·· · ··· ╎ ··· · ╎◆· · ··· ╎ ··· ]8;;]8;;]8;; · · ▜█▛▀▘ •▜█▛▀▘· ◆ · · · •· · · ·· · · • · · ╌ · · · • · · · · · · • · · ]8;;]8;;[?2026l[?25l[?2026h]8;; ╌· · ▗▄▄█▀ · · · ╌ · ╌ · ]8;;]8;;]8;; · ·· ███▄·· · ▗▄███▀ ··· ╎ ·· ◆· ·· ╎ ·· · ╎· · ·· ╎ ·◆ ┃·╌ ·· ╎ ·· · ╎· ]8;;]8;;]8;; ··◆ ·▗█▛▐█▙ ··▗▄█▀▗█▀· ··· ··╎·•· ·· ···╎ •· ··· ··┃·•· ·· ···╎ •· ··· ···╎·•· ·· ···╎ •· ··· ··┃·•· ·· ◆··╎·•·]8;;]8;;]8;;· ·· ▗█▛ ▟██▙▄██▛·▟▛ ·· ··╎ ◆· •· ··╎ ·· ·· ··┃ ·· •· ·╎ ·· ·◆ ··╎ ·· •· ·╎ ·· ·· ··┃ ·· •· ·╎ ·· · ·]8;;]8;;]8;; ·─ ··▗▟▌·▐███▛▘▗▄█▖··• · ╎·· · ··· ╎· ··• · ╎·· ╌· ··· ┃·─ ··• · ╎·· ─·· ··· ╎· ··• · ╎·· ·─ ··· ┃· ··• ]8;;]8;;]8;;·· ·▟███▄ ▄▄▟███▀ ·· ·· ··╌·· ╎· ·· ·· ·╎ ··· ·· ┃· ·· ·· ·╎· ·· ·· ╎· ·· ····╎ ◆· ·· ┃· ·· ·· ·]8;;]8;;]8;; · · ▜█▛▀▘ ·▜█▛▀▘· · · · ·╌· · · · · · ◆ · · · · · · · ··· · · · ··· · · · ]8;;]8;;[?2026l[?25l[?2026h]8;; · · ▗▄▄█▀ · · ╌ · · · · ]8;;]8;;]8;;· ·· ███▄·· ▗▄███▀ ·· ╎ ·· · ┃• ·· ╎ ·· ╎• ╌ ·· ╎ ◆· · ┃•·· ·· ╎ ·· ╎• ]8;;]8;;]8;;··◆·· ·▗█▛▐█▙ · ▗▄█▀▗█▀╌···· · ╎•·· · ···◆╎ · ···· · ┃•·· · ····╎ · ···· · ╎•·· · ····╎ · ···· · ┃•·· · ◆···╎ · ]8;;]8;;]8;; ··· ▗█▛•▟██▙▄██▛·▟▛ · ···╎ · •·· ·╎··· · ···┃ · •··· ·╎··· ◆ ···╎ · •·· ·╎··· · ···┃ · •··· ·╎··· ·]8;;]8;;]8;;· ··▗▟▌ ▐███▛▘▗▄█▖·•··· ─ ╎·◆ · ··· ╎ ·•· · ╎·· ·· ··· ┃ ─ ·•· · ╎·· · ··· ╎ · ·•· · ╎·· · ··· ┃ ·•· ]8;;]8;;]8;;╌ ··▟███▄··▄▄▟███▀··• · ··· · ·╎· · ··• ·╎ ··· · ·┃· · ··• ·╎ ···· · ·╎· · ··• ·╎ ◆·· · ·┃· · ··• ·]8;;]8;;]8;;· · ╌▜█▛▀▘· ▜█▛▀▘ · ··· ··· · · · · · · ◆ · · ·· · · · · ·· · · · · · ·· ]8;;]8;;[?2026l[?25l[?2026h]8;; · · ▗▄▄█▀ · · · · · · ]8;;]8;;]8;; ·· ███▄· ·▗▄███▀ ·· ╎ ·· ·• ·· ╌ ╎ ·· ··• · ·· ╎ ◆· ·• ·· ╎ ·· ·• ·]8;;]8;;]8;;··· ··▗█▛▐█▙·· ▗▄█▀▗█▀ ··· ╎·· •·· ···┃·◆ ·· ··· ╎·· •·· ·· ╎·· ·· ··· ╎·· •·· ·· ┃·· ·· ··· ╎·· •·· ·· ╎·· ·· ]8;;]8;;]8;; ◆· ▗█▛·▟██▙▄██▛·▟▛ ·· ·· ╎ ·· ·· ··╎·· ·· ·· ┃ ·· ·· ···╎·· ◆· ·· ╎··· ·· ··╎·· ·· ···┃ ·· ·· ·◆╎·· ··]8;;]8;;]8;;· ···▗▟▌ ▐███▛▘▗▄█▖•·· · ─··◆· • ·╎·─ · ·•· ┃ ··· • ··╎· ·· ·•· ╎ ···─ • ·╎· · ─·•· ┃ ···· • ·╎· · ·•· ]8;;]8;;]8;; ···▟███▄ ·▄▄▟███▀·• ·· ·· ·· ·╎ ·· ·• ··╎ ·· ·· ·┃ ·· ·• ··╎ ·· ·· ·╎ ·· ·• ··╎ ·· ·· ·┃ ·· ·• ··]8;;]8;;]8;;· ·· ▜█▛▀▘· ▜█▛▀▘ · · · • · · · · · ·· • · · · · · • · · · · · ◆ • · · · ]8;;]8;;[?2026l[?25l[?2026h]8;; · ▗▄▄█▀ · · · · · · · ]8;;]8;;]8;; ·· ███▄· ▗▄███▀ ··· ╎ ·· •┃ · ·· ·╎ ·· •╎ ·· ╎ ·· •┃ · ·· ╎ ·· •╎ ]8;;]8;;]8;;··· · ▗█▛▐█▙· ·▗▄█▀▗█▀·····╎· •··· · ·┃◆· · ···· ╎· •···· · ·╎·· · ···· ╎· •··◆ · ·┃·· · ···· ╎· •··· · ·╎·· · ·]8;;]8;;]8;;◆·· ▗█▛·▟██▙▄██▛·▟▛ · ··· ╎ · ··· · ╎·· · ··· ┃ · ··· · ╎·· · · ··· ╎ ······ · ╎·· · ··· ┃ · ··· ◆ ╎·· · ]8;;]8;;]8;; ···▗▟▌ ▐███▛▘▗▄█▖·· ·─ ··· • ·╎·─· •·· ·┃ ─··· • ·╎· ·─ •·◆ ·╎ ··· • ·╎· · ─•·· ·┃ ···─•· ·╎· · •·· ·]8;;]8;;]8;; ···▟███▄··▄▄▟███▀•· · ··◆·· ··╎ · ·•· · ╎ ··· ·╌ ··┃ · ·•· · ╎ ··· · ··╎ · ·•· ··╎ ··· · ··┃ · ·•· · ]8;;]8;;]8;; ·· ▜█▛▀▘ ▜█▛▀▘· · · • · · · · · · ·• · · · ··· · • · · · · · ◆ • · · · ·]8;;]8;;[?2026l[?25l[?2026h]8;; · ▗▄▄█▀ ·╌ · · · · ]8;;]8;;]8;; ·· ███▄ •▗▄███▀···┃ ·· ╎ •· ··╎ · ·· ╎ •· ···┃· ·· ╎ •· ···╎ ·· ╎ •· ]8;;]8;;]8;;·· ·· ▗█▛▐█▙· ·▗▄█▀▗█▀··· ╎· ··· ·· ·┃· ·· ··· ╎· ··· ····╎· ·· ··· ╎· ··◆ ·· ·┃· ·· ··· ╎· ··· ·· ·╎· ·· ·]8;;]8;;]8;;·· ·▗█▛·▟██▙▄██▛ ▟▛·· ·· ·• ·· ┃·· ◆· ·· ╎· ·• ·· ·╎····· ·· ╎· ·• ·· ┃·· ·· ·· ╎· ·• ·· ╎◆· ·· ·· ]8;;]8;;]8;; ··· ▗▟▌ ▐███▛▘▗▄█▖· ·─ ··· · ··╎ · •·· ·┃ ··· · ··╎ ─· •·◆ ·╎ ··· ·· ··╎ ·─ •·· ·┃ ··· ─· ··╎ ·· •·· ·]8;;]8;;]8;; ·· ▟███▄··▄▄▟███▀· ·· ╌·◆ ·• ╎ ·· ·· •·┃·· ·· ·• ╎ ·· ·· •·╎·· ·· ·• ╎ ·· ·· ·•·┃·· ··· ·• ╎ ·· ·· •·╎·· ]8;;]8;;]8;; · ▜█▛▀▘ ▜█▛▀▘· · · ╌·· · · · · · · ◆ · · · · ·· · · · · · ╌··· · · · · ·]8;;]8;;[?2026l[?25l[?2026h]8;;╌ · ▗▄▄█▀ · · · · ╌ ]8;;]8;;]8;; ·· ███▄ ·▗▄███▀ ··┃ ·· ╎ ·· ··╎ ··· ╎ ·· ··┃ ·· ╎ ·· ··╎ · ·· ╎ ·· ]8;;]8;;]8;;·· ·· ·▗█▛▐█▙ ··▗▄█▀▗█▀··· ╎ ···· · ··┃• · ····· ╎ ···· · ··╎• · ···· ╎ ··◆· · ··┃• · ···· ╎ ···· · ··╎• · ··]8;;]8;;]8;;·· •▗█▛·▟██▙▄██▛ ▟▛· ··· ·• ··· ┃· ◆·• · · ·╎· • ··· ╎· ··• · ·╎· • ··· ┃· ··• · ·╎· • ··· ╎· ··• ···]8;;]8;;]8;; ··· ▗▟▌ ▐███▛▘▗▄█▖· · ◆·· · ··╎ · ··· · ┃ ··· · ··╎ · ··· · ╎ ··· · ╌··╎·· ─ ··· · ┃ ··· · ··◆ · ··· · ]8;;]8;;]8;; ··· ▟███▄··▄▄▟███▀· · ····· • ╎··· · •··┃· ···· • ╎··· · •·◆╎· ··· • ╎··· · •··┃· ··· • ╎··· · •··╎· ]8;;]8;;]8;;· ·▜█▛▀▘ ▜█▛▀▘ · · · · · · · · · ◆ · · · · · ╌ · · · · ··· · ·· · · · · ]8;;]8;;[?2026l[?25l[?2026h]8;; · ▗▄▄█▀· · · ╌ · · ]8;;]8;;]8;; ·· ███▄ ··▗▄███▀···┃ ·· ╎·· ·· ╎ ·· ╎·· ···┃ · ··· ╎·· ·· ╎ ·· ╎·· ]8;;]8;;]8;;· ·• ·▗█▛▐█▙ ··▗▄█▀▗█▀·· ·• ··· ·╎ ··• ·····╎ ·• ··· ·╎···• ·· ··┃ ·• ··· ··╎ ··• ·· ··╎ ·• ··· ·╎ ··• ·· ··]8;;]8;;]8;;· •·▗█▛ ▟██▙▄██▛ ▟▛· ·· •· ·· ┃· ·•·╌ ·· ·╎ •· ·· ╎· ·•· ·· ·╎ •· ◆· ┃· ·• ·· ·╎ · •· ·· ╎· ·• ·· ·]8;;]8;;]8;; ··· ▗▟▌·▐███▛▘▗▄█▖ · ◆·· · ┃··· ◆ ···╎ · ─··· · ╎···─╌· ···╎ · ··· · ┃··· · ···╎·· ···─ · ╎··◆ · ─···╎ · ]8;;]8;;]8;; ·· •▟███▄· ▄▄▟███▀ ··· ·· •· ╎·· ·· ··· ┃· ·· •◆ ╎·· ·· ·◆ ╎· ·· •·· ╎·· ·· ·· ┃· ·· •· ╎·· ·· ·· ╎· ]8;;]8;;]8;;· ▜█▛▀▘ ▜█▛▀▘ · · · · · · · • · · · · · • · · · · · · • ◆ · · · · • · · ]8;;]8;;[?2026l[?25l[?2026h]8;; · ▗▄▄█▀ · ╌ · · · · ]8;;]8;;]8;; ·· · ███▄ ··▗▄███▀·· ┃ ·· ╎·· ·· ╎ ··╌ ╎·· ·· ┃ ╌ ·· ╎·· ·· ╎ ·· ╎·· ]8;;]8;;]8;;· • ··▗█▛▐█▙··•▗▄█▀▗█▀·· ·• ····· ·╎··•· · ···╎ • ···· ·╎··•· · ···┃ • ···· ·╎··•· · ···╎ • ···· ·╎··•· · ··◆]8;;]8;;]8;;· · ▗█▛ ▟██▙▄██▛ ▟▛ ··◆ · ··· ┃ ·•· ····╎ · ··· ╎ ·•· · ··╎ · ◆·· ┃ ·•·· · ··╎ · ··· ╎ ·•· · ··]8;;]8;;]8;;···• ·▗▟▌·▐███▛▘▗▄█▖─· ··• · ┃··· ◆ ···╎·─ ··• · ╎···─·· ◆··╎· ─··• · ┃··· ·─ ···╎· ··• · ╎··· · ─···╎· ]8;;]8;;]8;;··· ·▟███▄· ▄▄▟███▀ ·· ··· · ╎·· · ··· ┃ ··· ◆ ╎···· ····╎ ··· · ·╎·· · ··· ┃ ··· · ╎·◆ · ··· ╎ ]8;;]8;;]8;; ▜█▛▀▘ ▜█▛▀▘ · · · · · · · • ·· · · · · • · · ·· · · • ◆ ·· · · · • · ·]8;;]8;;[?2026l[?25l[?2026h]8;;  Share  ]8;;]8;;]8;; > Not now  ]8;;]8;;[?2026l[?25l[?2026h]8;;· ▗▄▄█▀ · · · · · · ]8;;]8;;]8;;·· ███▄ ·· ▗▄███▀· · ··· ╎ ·· ╎· ·· ┃ ·· ╎· ··· ╎ ·· ╎· ·· ┃ ·· ]8;;]8;;]8;; •· ··▗█▛▐█▙·•·▗▄█▀▗█▀· •· ··· ··╎·•· ·· ···╎ •· ··· ··╎·•· ······┃ •· ··· ··╎·•· ·· ···╎ •· ··· ··╎·•· ·· ···]8;;]8;;]8;; ·· ▗█▛ ▟██▙▄██▛ ▟▛ ·◆ ·· ··╎ ·◆ •· ·╎ ·· ·· ··╎ ··· •· ··┃··· ·· ··╎ ·· •· ·╎ ·· ·· ··╎ ·· •· ·┃ ◆·]8;;]8;;]8;;··• ·▗▟▌·▐███▛▘▗▄█▖─· ··• · ┃·· · ··· ╎·─ ··• · ╎·· · ·◆·· ╎· ··• ◆ ┃·· ─· ··· ╎· ··• · ╎··· ·─ ··· ╎· ]8;;]8;;]8;;·· ··▟███▄ ·▄▄▟███▀·· ····┃ ·· ·· ╎· ·· ·· ◆╎ ·· ·· ╎· ·· ·· ·┃ ·· ·· ╎· ·◆ ·· ·╎ ·◆ ·· ╎· ·· ]8;;]8;;]8;; ▜█▛▀▘ ▜█▛▀▘ · · · · · · · · · · ·· · ·· · · · · · · · · · · · · · · · ·]8;;]8;;[?2026l[?25l[?2026h]8;; ▗▄▄█▀· · · ]8;;]8;;]8;;·• · ███▄ ·· ▗▄███▀• ·· ╎ ·· ╎• ·· ·┃ ·· ╎• ·· ╎ ·· ╎• ·· ·┃ ·· ]8;;]8;;]8;; · ···▗█▛▐█▙•··▗▄█▀▗█▀· · ···· · ╎•·· ·╌····╎ · ····· · ╎•··· · ····┃ · ···· · ╎•·· · ····╎ · ···· · ╎•·· · ····]8;;]8;;]8;; · ·▗█▛·▟██▙▄██▛ ▟▛·◆· · ···╎ ◆ •·· ·╎··· · · ···╎ ··•·◆ ·┃··· · ···╎ · •··· ·╎··· · ···╎ · •·· ·┃◆··]8;;]8;;]8;;·•· · ▗▟▌◆▐███▛▘▗▄█▖· ·•· · ┃·· · ····╎ ··•◆ · ╎·· · ··· ╎ ─ ·•· ◆ ┃·· · ··· ╎ ·•· · ◆·· · ─ ····╎ ]8;;]8;;]8;;·• · ▟███▄ ·▄▄▟███▀· ··• ·┃ ··· · ·╎· · ··• ·╎ ··· ·╌ ·╎· · ··• ·┃ ··· · ··╎· ◆ ··• ·╎ ··· · ·╎· · ·]8;;]8;;]8;; ·▜█▛▀▘ ▜█▛▀▘ · ··╌ · · · · · · · · · · · · · · · · · · · ·· · · · · · · ]8;;]8;;[?2026l[?25l[?2026h]8;; ▗▄▄█▀ · · · · ]8;;]8;;]8;;• ·███▄ ····▗▄███▀ ·· ╎ ·· ·• ·· ┃· ·· ·• ·· · ╎ ··· ·• ·· ┃ ·· ·]8;;]8;;]8;; ·· ···▗█▛▐█▙·· ▗▄█▀▗█▀ ·· ··· ╎·◆ •·· ·· ╎·· ·· ··· ┃·· •·· ·· ╎·· ·· ··· ╎·· •·· ···╎·· ·· ··· ┃·· •·· ·· ╎·· ]8;;]8;;]8;; ·· ·▗█▛ ▟██▙▄██▛·▟▛·· ·· ·· ╎ ·· ·· · ··╎·· ·· ·· ╎ ·· ·◆ ··┃·· ·◆ ·· ╎ ·· ·· ···╎·· ·· ·· ╎ ·· ·· ··┃···]8;;]8;;]8;;•· · ▗▟▌·▐███▛▘▗▄█▖· ··•· ╎ ─··· • ·╎·· · ·•◆ ╎ ··· • ·┃· · ─·•· ╎ ···─ •· ·╎· · ·•· ╎ ◆·· • ·┃· ◆ ·]8;;]8;;]8;;• ·· ▟███▄··▄▄▟███▀· ·• ··┃ ·· ·· ·╎ ·· · ·• ··╎ ·· ·· · ·╎ ·· ·• ··┃ ··· ·· ·╎ ◆· ·• ··╎ ·· ·· ·╎ ·· ·]8;;]8;;]8;; ··▜█▛▀▘ ▜█▛▀▘ · · · · • · · · · · · • · · · · · • · ··· · · · • · ·· · ]8;;]8;;[?2026l[?25l[?2026h]8;; · ·▗▄▄█▀ · · · · · ]8;;]8;;]8;;· ·███▄ · ·· ▗▄███▀ ·· ╎ ·· •╎ ·· ┃···· · •╎ ·· ·╎ ·· •╎ ·· ┃ ·· · •]8;;]8;;]8;; · ····▗█▛▐█▙·· ▗▄█▀▗█▀ · ···· ╎◆ •··· ·· ·╎·· · ···· ┃· •··· · ·╎·· · ·····╎· •··· · ·╎·· · ···· ┃· •··· · ·╎·· ]8;;]8;;]8;; · ··▗█▛·▟██▙▄██▛·▟▛·· · ··· ╎ ····· · ╎·· · · ··· ╎ · ·◆· · ┃·· ◆ ··· ╎ · ··· · ╎·· ····◆ ╎ · ··· · ┃·· ]8;;]8;;]8;;·· ·─ ▗▟▌·▐███▛▘▗▄█▖ •·· ·╎ ─··· • ·╎·─· · •◆· ·╎ ··· • ·┃· ·─ •·· ·╎ ···─• ╌·╎◆ · ─•·· ·╎ ··· •─ ·┃· ◆ •]8;;]8;;]8;;· · ·▟███▄· ▄▄▟███▀ ·•· · ┃ ··· · ···╎ · ·•· · ╎ ··· · ··╎ · ·•· · ┃ ··· ·· ··╎ · ·•· · ╎ ····· ··╎ · ·•]8;;]8;;]8;; · ▜█▛▀▘ •▜█▛▀▘ · · · · · ·• · · · · · • · · · · · ╌ • · · · · · • · · · · ]8;;]8;;[?2026l[?25l[?2026h]8;; · ╌ ▗▄▄█▀ · · ╌ · ]8;;]8;;]8;; ··███▄ ·· ▗▄███▀ ··╎ ·· ·┃ •· ··╎ ·· ╎ •· ··╎ ·· ·┃ •· ··╎ ·· ╎ •·]8;;]8;;]8;;·· ··· ▗█▛▐█▙· ▗▄█▀▗█▀ ·· ··· ◆· ··· ··· ·╎·· ·· ··· ┃· ·◆· ····╎· ·◆···· ╎· ··· ··· ·╎· ·· ··· ┃· ··· ·· ·╎· ]8;;]8;;]8;;··· ··▗█▛·▟██▙▄██▛·▟▛· ·· ╎· ·• ·· ╎·· ·· ·· ┃· ·• ·· ╎·· ··· ·· ╎· · ·• ·· ╎·· ·· ·· ┃◆ ·• ·· ╎·◆ ·· ]8;;]8;;]8;;· ─· ▗▟▌ ▐███▛▘▗▄█▖ •·· ·╎ ··· ·· ··╎ ─· •◆· ·╎ ··· · ··┃ ╌·─·•·· ·╎ ··· · ··◆ · •·· ·╎ ··· ─· ··┃ · •·]8;;]8;;]8;; ·· ▟███▄• ▄▄▟███▀ •· ·· ··· ·• ╎ ·· ·· •·╎·· ·· ·• ┃ ····· •·╎·· ·· ·• ╎ ·· ·· •·╎·· ·· ·• ┃ ·· ·· •·]8;;]8;;]8;; · ▜█▛▀▘ ·▜█▛▀▘ ··· · · · · · · · · ·· · · · · · · · · · · · · · · · · · ]8;;]8;;[?2026l[?25l[?2026h]8;; · · ▗▄▄█▀ · ╌ · · · ]8;;]8;;]8;; ··███▄ ·· ▗▄███▀ ·◆╎ ·· ·┃ ·· ··╎ ··╌ ╎ ·· ··╎ ╌ ·· ┃ ·· ··╎ ·· ╎ ··]8;;]8;;]8;;· ···· ▗█▛▐█▙· ▗▄█▀▗█▀ · ···· ╎ ····╌ · ··╎• ·· ···· ·┃ ·◆··· · ··╎• ◆ ···· ╎ ···· · ··╎• · ···· ┃ ···· · ··╎• ]8;;]8;;]8;; · ···▗█▛•▟██▙▄██▛ ▟▛• · ·╎· • ··· ╎· ··• ···┃· • ··· ╎· ··•·· · ·╎· • ··· ╎· ··• · ·◆· • ··· ╎◆ ··• ]8;;]8;;]8;;· · ▗▟▌·▐███▛▘▗▄█▖ ··· · ╎ ··· · ··╎ · · ··· · ╎ ··· · ··┃ ·· ··· · ╎···· · ·◆╎ · ─ ··· · ╎ ··· · ··┃ · ··]8;;]8;;]8;; · ▟███▄ ▄▄▟███▀ •·· · ··· • ╎··· · · •◆·╎· ····• ┃····· •··╎· ··· • ╎··· · •··╎· ··· • ┃··· · •··]8;;]8;;]8;; · ·▜█▛▀▘· ▜█▛▀▘ ·╌· · · · · · · · · · · · · · · · ·· · · · · · · · · · ]8;;]8;;[?2026l[?25l[?2026h]8;; ·· ▗▄▄█▀ · · · · ]8;;]8;;]8;; ·· ███▄ ·· ▗▄███▀ ·◆ ╎ ·· ·· ┃·· ·· ╎ ·· · ╎·· ·· ╎ ·· ┃·· · ·· ╎ ·· ╎·· ]8;;]8;;]8;;· ··· ▗█▛▐█▙ ·▗▄█▀▗█▀·· ··╎ ·• ···· ·┃ ··• ·· ··╎ ·• ◆·· ·╎ ··• ◆· ··╎ ·• ··· ·┃···• ····◆╎ ·• ··· ·◆ ··• ·]8;;]8;;]8;;·· ·· ▗█▛·▟██▙▄██▛ ▟▛ ·· ·╎ •· ·· ·╎· ·• ····┃ •· ··· ╎· ·• ·· ·╎ •· ·· ╎· ·• ·· ·┃ •· ·· ╎· ·• ]8;;]8;;]8;;─ · ·▗▟▌ ▐███▛▘▗▄█▖ ··· · ··· · ╎···─ · ···┃ · ─··· · ╎··· · ····╎ · ··· · ╎·◆· ·· ─···┃ · ···─ · ╎··· · ···]8;;]8;;]8;;·· ▟███▄ ▄▄▟███▀ ·· ·· ·· •· ╎·· ··· ·◆· ╎· ·· •· ┃·· ·· ·· ╎· ·· •· ·╎·· ·· ·· ╎· ·· •· ┃·· ·· ··· ]8;;]8;;]8;; · ◆▜█▛▀▘· ▜█▛▀▘·· • · · · · · • · · · ·· · • · · · ··· · • · · · · · · • ]8;;]8;;[?2026l[?25l[?2026h]8;; ·· ▗▄▄█▀ · · · · · ]8;;]8;;]8;; ·· ███▄··· ▗▄███▀ ◆· ╎ ·· · ┃·· ·· ╎ ·· ╎·◆ · ·· ╎ ·· ┃·· ·· ╎ ·· ╎·· ]8;;]8;;]8;; ···· ▗█▛▐█▙ ·▗▄█▀▗█▀· ···╎ • ···· ··┃··•· ·╌···╎ • ◆··· ·╎··•·· · ···╎ • ····· ·┃··•· · ·◆·╎ • ···· ◆╎··•· ·]8;;]8;;]8;;· ··· ▗█▛ ▟██▙▄██▛·▟▛ · ··╎ ··· ··· ╎··•· · ··┃ · ··· ╎ ·•· · ··╎ · ··· ╎ ·•·· · ··┃ · ··· ╎ ·•· ]8;;]8;;]8;;─· ·▗▟▌·▐███▛▘▗▄█▖ ··· ·─ ··• · ·╎··· · ···┃· ─··• ·· ╎···─· ···╎· ··• · ╎··· ·─· ···┃· ··•─· ╎··· · ─···]8;;]8;;]8;;· ·▟███▄ ·▄▄▟███▀ ··· · ··· · ╎·· · · ◆·· ╎ ··· · ┃·· · ··· ╎ ···· · ╎◆· · ··· ╎ ··· · ┃·· · ··· ]8;;]8;;]8;; ··◆ ▜█▛▀▘ ·▜█▛▀▘· • · · · · · • · · · · · · • · · · · · •· · · · · · ··• ]8;;]8;;[?2026l[?25l[?2026h]8;; ·╌ ▗▄▄█▀ · · · · ]8;;]8;;]8;; ·· ███▄·· ▗▄███▀ ◆· ·· ╌╎ ···· ╎· ·· ╎ · ·◆ ┃· ·· ·╎ ·· ╎· ·· ╎ ·· ]8;;]8;;]8;; ··· ·•▗█▛▐█▙ ··▗▄█▀▗█▀· ···╎ •· ··· ··┃·•· ·· ···╎ •· ··· ··╎·•· ·· ···╎ •· ··· ··┃·•· ·· ◆··╎ •· ··· ◆·╎·•· ··]8;;]8;;]8;;···· ▗█▛ ▟██▙▄██▛•▟▛· ·· ·· ···· ··┃ ·· •· ·╎ ·· · ·◆ ··╎ ·· •· ·╎··· ·· ··┃ ·· •· ··╎ ··· ·· ··╎ ·· •· ·]8;;]8;;]8;; · ··▗▟▌·▐███▛▘▗▄█▖··· ─· ··•· ·· ╎·· · ··· ┃·─ ··• · ╎·· ─·· ··· ╎· ··• · ╎·· ·─ ··· ┃· ···• · ╎·· · ··· ]8;;]8;;]8;;·· ·▟███▄╌ ▄▄▟███▀ ·· ·· ·· ·╎ · ·· ·· ┃···· ·· ·╎ ·· ·· ··╎· ·· ·· ·╎ ◆· ·· ┃· ·· ····╎ ·· ·· ╎· ·]8;;]8;;]8;; · · ▜█▛▀▘ ·▜█▛▀▘· · · · · · · · ◆ · · · · · · · · · · · · · · · · · ·· · ]8;;]8;;[?2026l[?25l[?25l[?2026h ws $0.00]8;;]8;;  ▗▄▄█▀ ]8;;  ███▄ ▗▄███▀ ]8;;  ▗█▛▐█▙ ▗▄█▀▗█▀ prime agent v0.9.8 ]8;;  ▗█▛ ▟██▙▄██▛ ▟▛ model inclusionai/ling-3.0-flash-sante:free ]8;;  ▗▟▌ ▐███▛▘▗▄█▖ cwd ~/ws ]8;; ▟███▄ ▄▄▟███▀ ]8;; ▜█▛▀▘ ▜█▛▀▘ ]8;;]8;;[Skill warning] ]8;; auto (project) /Users/xxxxxx/.agents/skills/orchestration/SKILL.md ]8;; description exceeds 1024 characters (1038) ]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;  Details mode (Ctrl+O to expand) ]8;; ]8;; >   ]8;; ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 0 (0%)]8;;[?2026l[?25l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/prime-agent-0-9-8-ready.meta.json b/src/main/runtime/__fixtures__/prime-agent-0-9-8-ready.meta.json new file mode 100644 index 000000000000..0f3e13d39ab0 --- /dev/null +++ b/src/main/runtime/__fixtures__/prime-agent-0-9-8-ready.meta.json @@ -0,0 +1,15 @@ +{ + "capturedAt": "2026-09-30T19:18:27.840Z", + "platform": "darwin", + "command": [ + "prime-agent", + "--provider", + "openrouter", + "--model", + "inclusionai/ling-3.0-flash-sante:free" + ], + "cols": 120, + "rows": 40, + "note": "prime-agent 0.9.8, macOS, OpenRouter inclusionai/ling-3.0-flash-sante:free, isolated HOME, later launch, settled empty composer", + "exitCode": 129 +} diff --git a/src/main/runtime/__fixtures__/prime-agent-0-9-8-ready.txt b/src/main/runtime/__fixtures__/prime-agent-0-9-8-ready.txt new file mode 100644 index 000000000000..1b4564b6c02c --- /dev/null +++ b/src/main/runtime/__fixtures__/prime-agent-0-9-8-ready.txt @@ -0,0 +1,2 @@ +Prime Agent sends pseudonymous usage and performance metrics without prompts, responses, tool content, file paths, or repository data. Disable this with telemetry.enabled=false, PRIME_AGENT_TELEMETRY=0, DO_NOT_TRACK=1, or offline mode. +[?2004h]10;?\]11;?\[?u[?25l[?1049h[?25l[?1002h[?1006h[?2026h ws]8;;]8;;  ▗▄▄█▀ ]8;;  ███▄ ▗▄███▀ ]8;;  ▗█▛▐█▙ ▗▄█▀▗█▀ prime agent v0.9.8 ]8;;  ▗█▛ ▟██▙▄██▛ ▟▛ model — ]8;;  ▗▟▌ ▐███▛▘▗▄█▖ cwd ~/ws ]8;; ▟███▄ ▄▄▟███▀ ]8;; ▜█▛▀▘ ▜█▛▀▘ ]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;  Details mode (Ctrl+O to expand) ]8;; ]8;; >   ]8;; ]8;;← manage ]8;;[?2026l[?25l]0;prime-agent - ws[?2026h ws $0.00]8;;  ▗█▛ ▟██▙▄██▛ ▟▛ model inclusionai/ling-3.0-flash-sante:free ]8;;[Skill warning] ]8;; auto (project) /Users/xxxxxx/.agents/skills/orchestration/SKILL.md ]8;; description exceeds 1024 characters (1038) ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 0 (0%)]8;;[?2026l[?25l[?2026h[?2026l[?25l[>4;2m \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/prime-agent-0-9-8-slash-menu.meta.json b/src/main/runtime/__fixtures__/prime-agent-0-9-8-slash-menu.meta.json new file mode 100644 index 000000000000..7caaa43281a8 --- /dev/null +++ b/src/main/runtime/__fixtures__/prime-agent-0-9-8-slash-menu.meta.json @@ -0,0 +1,15 @@ +{ + "capturedAt": "2026-09-30T19:18:52.004Z", + "platform": "darwin", + "command": [ + "prime-agent", + "--provider", + "openrouter", + "--model", + "inclusionai/ling-3.0-flash-sante:free" + ], + "cols": 120, + "rows": 40, + "note": "prime-agent 0.9.8, macOS, OpenRouter inclusionai/ling-3.0-flash-sante:free, isolated HOME, slash-command menu left open", + "exitCode": 129 +} diff --git a/src/main/runtime/__fixtures__/prime-agent-0-9-8-slash-menu.txt b/src/main/runtime/__fixtures__/prime-agent-0-9-8-slash-menu.txt new file mode 100644 index 000000000000..63877dcf099e --- /dev/null +++ b/src/main/runtime/__fixtures__/prime-agent-0-9-8-slash-menu.txt @@ -0,0 +1 @@ +[?2004h]10;?\]11;?\[?u[?25l[?1049h[?25l[?1002h[?1006h[?2026h ws]8;;]8;;  ▗▄▄█▀ ]8;;  ███▄ ▗▄███▀ ]8;;  ▗█▛▐█▙ ▗▄█▀▗█▀ prime agent v0.9.8 ]8;;  ▗█▛ ▟██▙▄██▛ ▟▛ model — ]8;;  ▗▟▌ ▐███▛▘▗▄█▖ cwd ~/ws ]8;; ▟███▄ ▄▄▟███▀ ]8;; ▜█▛▀▘ ▜█▛▀▘ ]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;  Details mode (Ctrl+O to expand) ]8;; ]8;; >   ]8;; ]8;;← manage ]8;;[?2026l[?25l]0;prime-agent - ws[?2026h ws $0.00]8;;  ▗█▛ ▟██▙▄██▛ ▟▛ model inclusionai/ling-3.0-flash-sante:free ]8;;[Skill warning] ]8;; auto (project) /Users/xxxxxx/.agents/skills/orchestration/SKILL.md ]8;; description exceeds 1024 characters (1038) ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 0 (0%)]8;;[?2026l[?25l[?2026h[?2026l[?25l[>4;2m[?25l[?2026h]8;; ]8;;]8;;]8;; › settings ]8;;]8;;]8;; model [search] ]8;;]8;;]8;; effort [off/high] ]8;;]8;;]8;; fast ]8;;]8;;]8;; tier [default|flex|priority|auto] ]8;;]8;;]8;;  ↓ 61 more ]8;;]8;;]8;; ]8;;]8;;]8;;  Open settings menu ]8;;]8;;]8;; ]8;;]8;; > /  ]8;;[?2026l[?25l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/prime-agent-0-9-8-tool-turn.meta.json b/src/main/runtime/__fixtures__/prime-agent-0-9-8-tool-turn.meta.json new file mode 100644 index 000000000000..4054c734015a --- /dev/null +++ b/src/main/runtime/__fixtures__/prime-agent-0-9-8-tool-turn.meta.json @@ -0,0 +1,15 @@ +{ + "capturedAt": "2026-09-30T19:20:31.748Z", + "platform": "darwin", + "command": [ + "prime-agent", + "--provider", + "openrouter", + "--model", + "inclusionai/ling-3.0-flash-sante:free" + ], + "cols": 120, + "rows": 40, + "note": "prime-agent 0.9.8, macOS, OpenRouter inclusionai/ling-3.0-flash-sante:free, isolated HOME, a turn that asks for a shell command, recording left running", + "exitCode": 129 +} diff --git a/src/main/runtime/__fixtures__/prime-agent-0-9-8-tool-turn.txt b/src/main/runtime/__fixtures__/prime-agent-0-9-8-tool-turn.txt new file mode 100644 index 000000000000..16a834569ef9 --- /dev/null +++ b/src/main/runtime/__fixtures__/prime-agent-0-9-8-tool-turn.txt @@ -0,0 +1 @@ +[?2004h]10;?\]11;?\[?u[?25l[?1049h[?25l[?1002h[?1006h[?2026h ws]8;;]8;;  ▗▄▄█▀ ]8;;  ███▄ ▗▄███▀ ]8;;  ▗█▛▐█▙ ▗▄█▀▗█▀ prime agent v0.9.8 ]8;;  ▗█▛ ▟██▙▄██▛ ▟▛ model — ]8;;  ▗▟▌ ▐███▛▘▗▄█▖ cwd ~/ws ]8;; ▟███▄ ▄▄▟███▀ ]8;; ▜█▛▀▘ ▜█▛▀▘ ]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;  Details mode (Ctrl+O to expand) ]8;; ]8;; >   ]8;; ]8;;← manage ]8;;[?2026l[?25l]0;prime-agent - ws[?2026h ws $0.00]8;;  ▗█▛ ▟██▙▄██▛ ▟▛ model inclusionai/ling-3.0-flash-sante:free ]8;;[Skill warning] ]8;; auto (project) /Users/xxxxxx/.agents/skills/orchestration/SKILL.md ]8;; description exceeds 1024 characters (1038) ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 0 (0%)]8;;[?2026l[?25l[?2026h[?2026l[?25l[>4;2m[?2026h > Use your shell tool to run: rm note.txt  ]8;;[?2026l[?25l[?2026h >   ]8;;[?2026l[?25l[?2026h]133;A ]8;; Use your shell tool to run: rm note.txt ]8;;]133;B]133;C ]8;; ⠋ Waiting · 0s ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠙ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠹ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠸ Waiting · 0s ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠼ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠴ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠦ Waiting · 0s ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠧ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠇ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠏ Waiting · 0s ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠋ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠙ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠹ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠹ Waiting · 1s ]8;;[?2026l[?25l[?2026h ⠸ Waiting · 1s ]8;;[?2026l[?25l[?2026h ⠼ Waiting · 1s ]8;;[?2026l[?25l[?2026h]133;A]8;;]133;B]133;C The user wants me to run ]8;; ⠼ Thinking · 1s · ↓ 6 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 6 (0%)]8;;[?2026l[?25l[?2026h]133;B]133;C The user wants me to run `rm note.txt ]8;; ⠴ Thinking · 1s · ↓ 9 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 9 (0%)]8;;[?2026l[?25l[?2026h]133;B]133;C The user wants me to run rm note.txt using the shell tool (bash). ]8;; ⠴ Thinking · 1s · ↓ 17 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 17 (0%)]8;;[?2026l[?25l[?2026h]8;; The user wants me to run rm note.txt using the shell tool (bash). ]8;; bash · queued ]8;; ]8;; $ ... ]8;; ⠴ Writing code · 1s · ↓ 20 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 20 (0%)]8;;[?2026l[?25l[?2026h $ rm note.txt ]8;; ⠴ Writing code · 1s · ↓ 23 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 23 (0%)]8;;[?2026l[?25l[?2026h ⠦ Writing code · 1s · ↓ 23 tokens ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h bash · ◈ running ]8;; ⠦ Executing · 1s · ↑ 34 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 34 (0%)]8;;[?2026l[?25l[?2026h bash · error ]8;; ]8;; Tool bash not found ]8;; ]8;; Took 0.0s ]8;; ⠦ Waiting · 1s · ↑ 34 tokens ]8;;[?2026l[?25l[?2026h ⠧ Waiting · 1s · ↑ 34 tokens ]8;;[?2026l[?25l[?2026h ⠇ Waiting · 1s · ↑ 34 tokens ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠏ Waiting · 1s · ↑ 34 tokens ]8;;[?2026l[?25l[?2026h ⠋ Waiting · 1s · ↑ 34 tokens ]8;;[?2026l[?25l[?2026h ⠙ Waiting · 1s · ↑ 34 tokens ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠹ Waiting · 1s · ↑ 34 tokens ]8;;[?2026l[?25l[?2026h ⠸ Waiting · 1s · ↑ 34 tokens ]8;;[?2026l[?25l[?2026h ⠼ Waiting · 1s · ↑ 34 tokens ]8;;[?2026l[?25l[?2026h ⠼ Waiting · 2s · ↑ 34 tokens ]8;;[?2026l[?25l[?2026h ⠴ Waiting · 2s · ↑ 34 tokens ]8;;[?2026l[?25l[?2026h ⠦ Waiting · 2s · ↑ 34 tokens ]8;;[?2026l[?25l[?2026h ⠧ Waiting · 2s · ↑ 34 tokens ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠇ Waiting · 2s · ↑ 34 tokens ]8;;[?2026l[?25l[?2026h ⠏ Waiting · 2s · ↑ 34 tokens ]8;;[?2026l[?25l[?2026h ⠋ Waiting · 2s · ↑ 34 tokens ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠙ Waiting · 2s · ↑ 34 tokens ]8;;[?2026l[?25l[?2026h ⠹ Waiting · 2s · ↑ 34 tokens ]8;;[?2026l[?25l[?2026h]133;A]8;;]133;B]133;C The bash ]8;; ⠹ Thinking · 2s · ↓ 36 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 36 (0%)]8;;[?2026l[?25l[?2026h]133;B]133;C The bash tool ]8;; ⠹ Thinking · 2s · ↓ 37 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 37 (0%)]8;;[?2026l[?25l[?2026h]133;B]133;C The bash tool isn't available directly, ]8;; ⠹ Thinking · 2s · ↓ 44 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 44 (0%)]8;;[?2026l[?25l[?2026h]133;B]133;C The bash tool isn't available directly, but I can use ]8;; ⠸ Thinking · 2s · ↓ 47 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 47 (0%)]8;;[?2026l[?25l[?2026h]133;B]133;C The bash tool isn't available directly, but I can use Python's `os ]8;; ⠸ Thinking · 2s · ↓ 51 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 51 (0%)]8;;[?2026l[?25l[?2026h]133;B]133;C The bash tool isn't available directly, but I can use Python's os ]8;;[?2026l[?25l[?2026h]133;B]133;C The bash tool isn't available directly, but I can use Python's os module to remove ]8;; ⠸ Thinking · 2s · ↓ 55 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 55 (0%)]8;;[?2026l[?25l[?2026h]133;B]133;C The bash tool isn't available directly, but I can use Python's os module to remove the file, or ]8;; ⠸ Thinking · 2s · ↓ 58 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 58 (0%)]8;;[?2026l[?25l[?2026h]133;B]133;C The bash tool isn't available directly, but I can use Python's os module to remove the file, or use `ipython ]8;; ⠼ Thinking · 2s · ↓ 62 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 62 (0%)]8;;[?2026l[?25l[?2026h]133;B]133;C The bash tool isn't available directly, but I can use Python's os module to remove the file, or use ipython to run the ]8;; ⠼ Thinking · 2s · ↓ 65 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 65 (0%)]8;;[?2026l[?25l[?2026h  ▗▄▄█▀ ]8;;  ███▄ ▗▄███▀ ]8;;  ▗█▛▐█▙ ▗▄█▀▗█▀ prime agent v0.9.8 ]8;;  ▗█▛ ▟██▙▄██▛ ▟▛ model inclusionai/ling-3.0-flash-sante:free ]8;;  ▗▟▌ ▐███▛▘▗▄█▖ cwd ~/ws ]8;; ▟███▄ ▄▄▟███▀ ]8;; ▜█▛▀▘ ▜█▛▀▘ ]8;;]8;;[Skill warning] ]8;; auto (project) /Users/xxxxxx/.agents/skills/orchestration/SKILL.md ]8;; description exceeds 1024 characters (1038) ]8;;]8;;]133;A ]8;; Use your shell tool to run: rm note.txt ]8;;]133;B]133;C ]8;;]8;; The user wants me to run rm note.txt using the shell tool (bash). ]8;;]8;; bash · error ]8;; ]8;; $ rm note.txt ]8;; ]8;; Tool bash not found ]8;; ]8;; Took 0.0s ]8;;]8;; The bash tool isn't available directly, but I can use Python's os module to remove the file, or use ipython to run the ]8;; command. ]8;; ◈ python · waiting for code]8;; ⠼ Writing code · 2s · ↓ 67 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 67 (0%)]8;;[?2026l[?25l[?2026h ⠴ Writing code · 2s · ↓ 67 tokens ]8;;[?2026l[?25l[?2026h ◈ python · import · ↑ 1 lines]8;; ⠴ Writing code · 2s · ↓ 71 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 71 (0%)]8;;[?2026l[?25l[?2026h ◈ python · os.remove · ↑ 2 lines]8;; ⠴ Writing code · 2s · ↓ 74 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 74 (0%)]8;;[?2026l[?25l[?2026h ◈ python · os.remove('note.txt · ↑ 2 lines]8;; ⠴ Writing code · 2s · ↓ 77 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 77 (0%)]8;;[?2026l[?25l[?2026h ◈ python · os.remove('note.txt') · ↑ 3 lines]8;; ⠴ Writing code · 2s · ↓ 80 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 80 (0%)]8;;[?2026l[?25l[?2026h ⠦ Writing code · 2s · ↓ 84 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 84 (0%)]8;;[?2026l[?25l[?2026h ⠦ Writing code · 2s · ↓ 85 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 85 (0%)]8;;[?2026l[?25l[?2026h ⠦ Writing code · 3s · ↓ 98 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 98 (0%)]8;;[?2026l[?25l[?2026h ✓ python · os.remove('note.txt') · ↑ 3 ↓ 1 lines · 3ms]8;; ⠦ Waiting · 3s · ↑ 98 tokens ]8;;[?2026l[?25l[?2026h ⠧ Waiting · 3s · ↑ 98 tokens ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠇ Waiting · 3s · ↑ 98 tokens ]8;;[?2026l[?25l[?2026h ⠏ Waiting · 3s · ↑ 98 tokens ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠋ Waiting · 3s · ↑ 98 tokens ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠙ Waiting · 3s · ↑ 98 tokens ]8;;[?2026l[?25l[?2026h ⠹ Waiting · 3s · ↑ 98 tokens ]8;;[?2026l[?25l[?2026h ⠸ Waiting · 3s · ↑ 98 tokens ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠼ Waiting · 3s · ↑ 98 tokens ]8;;[?2026l[?25l[?2026h ⠴ Waiting · 3s · ↑ 98 tokens ]8;;[?2026l[?25l[?2026h  ▗█▛▐█▙ ▗▄█▀▗█▀ prime agent v0.9.8 ]8;;  ▗█▛ ▟██▙▄██▛ ▟▛ model inclusionai/ling-3.0-flash-sante:free ]8;;  ▗▟▌ ▐███▛▘▗▄█▖ cwd ~/ws ]8;; ▟███▄ ▄▄▟███▀ ]8;; ▜█▛▀▘ ▜█▛▀▘ ]8;;]8;;[Skill warning] ]8;; auto (project) /Users/xxxxxx/.agents/skills/orchestration/SKILL.md ]8;; description exceeds 1024 characters (1038) ]8;;]8;;]8;;]133;A ]8;; Use your shell tool to run: rm note.txt ]8;;]133;B]133;C ]8;;]8;; The user wants me to run rm note.txt using the shell tool (bash). ]8;; bash · error ]8;; ]8;; $ rm note.txt ]8;; Tool bash not found ]8;; Took 0.0s ]8;;]8;; The bash tool isn't available directly, but I can use Python's os module to remove the file, or use ipython to run the ]8;; command. ]8;;]8;; ✓ python · os.remove('note.txt') · ↑ 3 ↓ 1 lines · 3ms]8;;]133;A]8;;]133;B]133;C The file ]8;; ⠴ Thinking · 3s · ↓ 100 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 100 (0%)]8;;[?2026l[?25l[?2026h]133;B]133;C The file note.txt ]8;; ⠴ Thinking · 3s · ↓ 103 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 103 (0%)]8;;[?2026l[?25l[?2026h ⠦ Thinking · 3s · ↓ 103 tokens ]8;;[?2026l[?25l[?2026h]133;B]133;C The file note.txt has been deleted successfully ]8;; ⠦ Thinking · 3s · ↓ 110 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 110 (0%)]8;;[?2026l[?25l[?2026h  ▗▟▌ ▐███▛▘▗▄█▖ cwd ~/ws ]8;; ▟███▄ ▄▄▟███▀ ]8;; ▜█▛▀▘ ▜█▛▀▘ ]8;;]8;;[Skill warning] ]8;; auto (project) /Users/xxxxxx/.agents/skills/orchestration/SKILL.md ]8;; description exceeds 1024 characters (1038) ]8;;]8;;]8;;]133;A ]8;; Use your shell tool to run: rm note.txt ]8;;]133;B]133;C ]8;;]8;; The user wants me to run rm note.txt using the shell tool (bash). ]8;; bash · error ]8;; ]8;; $ rm note.txt ]8;; Tool bash not found ]8;; Took 0.0s ]8;;]8;; The bash tool isn't available directly, but I can use Python's os module to remove the file, or use ipython to run the ]8;; command. ]8;;]8;; ✓ python · os.remove('note.txt') · ↑ 3 ↓ 1 lines · 3ms]8;;]133;A]8;; The file note.txt has been deleted successfully. ]8;;]8;;]133;B]133;C Done — ` ]8;; ⠦ Writing · 3s · ↓ 113 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 113 (0%)]8;;[?2026l[?25l[?2026h]133;B]133;C Done — note.txt has ]8;; ⠦ Writing · 3s · ↓ 116 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 116 (0%)]8;;[?2026l[?25l[?2026h]133;B]133;C Done — note.txt has been removed. ]8;; ⠦ Writing · 3s · ↓ 119 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 119 (0%)]8;;[?2026l[?25l[?2026h ⠧ Writing · 3s · ↓ 119 tokens ]8;;[?2026l[?25l[?2026h ⠧ Waiting · 3s · ↑ 121 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 121 (0%)]8;;[?2026l[?25l[?2026h  ▗█▛▐█▙ ▗▄█▀▗█▀ prime agent v0.9.8 ]8;;  ▗█▛ ▟██▙▄██▛ ▟▛ model inclusionai/ling-3.0-flash-sante:free ]8;;  ▗▟▌ ▐███▛▘▗▄█▖ cwd ~/ws ]8;; ▟███▄ ▄▄▟███▀ ]8;; ▜█▛▀▘ ▜█▛▀▘ ]8;;]8;;[Skill warning] ]8;; auto (project) /Users/xxxxxx/.agents/skills/orchestration/SKILL.md ]8;; description exceeds 1024 characters (1038) ]8;;]8;;]8;;]133;A ]8;; Use your shell tool to run: rm note.txt ]8;;]133;B]133;C ]8;; The user wants me to run rm note.txt using the shell tool (bash). ]8;;]8;; bash · error ]8;; $ rm note.txt ]8;; Tool bash not found ]8;; ]8;; Took 0.0s ]8;;]8;; The bash tool isn't available directly, but I can use Python's os module to remove the file, or use ipython to run the ]8;; command. ]8;;]8;; ✓ python · os.remove('note.txt') · ↑ 3 ↓ 1 lines · 3ms]8;;]133;A]8;; The file note.txt has been deleted successfully. ]8;;]133;B]133;C Done — note.txt has been removed. ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 8.2k (3%)]8;;[?2026l[?25l[?2026h[?2026l[?25l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/prime-agent-0-9-8-trace-question.meta.json b/src/main/runtime/__fixtures__/prime-agent-0-9-8-trace-question.meta.json new file mode 100644 index 000000000000..28359d31a05f --- /dev/null +++ b/src/main/runtime/__fixtures__/prime-agent-0-9-8-trace-question.meta.json @@ -0,0 +1,15 @@ +{ + "capturedAt": "2026-09-30T19:18:01.693Z", + "platform": "darwin", + "command": [ + "prime-agent", + "--provider", + "openrouter", + "--model", + "inclusionai/ling-3.0-flash-sante:free" + ], + "cols": 120, + "rows": 40, + "note": "prime-agent 0.9.8, macOS, OpenRouter inclusionai/ling-3.0-flash-sante:free, isolated HOME, first launch: trace-sharing question left unanswered. Truncated 804501 -> 19763 bytes, after the first frame with the whole trace question painted.", + "exitCode": 129 +} diff --git a/src/main/runtime/__fixtures__/prime-agent-0-9-8-trace-question.txt b/src/main/runtime/__fixtures__/prime-agent-0-9-8-trace-question.txt new file mode 100644 index 000000000000..e757103533e2 --- /dev/null +++ b/src/main/runtime/__fixtures__/prime-agent-0-9-8-trace-question.txt @@ -0,0 +1 @@ +[?2004h]10;?\]11;?\[?u[?25l[?1049h[?25l[?1002h[?1006h[?2026h ws]8;;]8;;  ▗▄▄█▀ ]8;;  ███▄ ▗▄███▀ ]8;;  ▗█▛▐█▙ ▗▄█▀▗█▀ prime agent v0.9.8 ]8;;  ▗█▛ ▟██▙▄██▛ ▟▛ model — ]8;;  ▗▟▌ ▐███▛▘▗▄█▖ cwd ~/ws ]8;; ▟███▄ ▄▄▟███▀ ]8;; ▜█▛▀▘ ▜█▛▀▘ ]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;  Details mode (Ctrl+O to expand) ]8;; ]8;; >   ]8;; ]8;;← manage ]8;;[?2026l[?25l[?2026h  ▗█▛ ▟██▙▄██▛ ▟▛ model inclusionai/ling-3.0-flash-sante:free ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 0 (0%)]8;;[?2026l[?25l]0;prime-agent - ws[?25l[?2026h]8;; ]8;;]8;;]8;;· ▗▄▄█▀ · · · · · ]8;;]8;;]8;;◆ ·███▄ ····▗▄███▀ · ·· ╎ ·· ·• ·· ┃· ·· ·• · ·· · ╎ ·· ·• ·· ┃ ·· ·]8;;]8;;]8;; ·· ···▗█▛▐█▙·· ▗▄█▀▗█▀ ·· ◆·· ╎·· •·· ◆· ╎·· ·· ··· ┃·· •·· ·· ╎◆· ·· ··· ╎·◆ •·· ·· ╎·· ·· ··· ┃·· •·· ···╎·· ]8;;]8;;]8;; ·· ·▗█▛ ▟██▙▄██▛·▟▛·· ·· ·· ╎ ·· ·· ··╎·· ·· ·· ╎ ·· ·· ··┃·· ·· ·· ╎ ·· ·· ···╎·· ·· ·· ╎ ·· ·◆ ··┃·· ]8;;]8;;]8;;•· · ▗▟▌·▐███▛▘▗▄█▖· ·•·─ ╎ ··· • ·╎·· · ·•· ╎ ·◆· • ·┃·─ · ·•·· ╎ ─··· •· ·╎· · ·•◆ ╎ ··· ·• ·┃· · ─·]8;;]8;;]8;;• ·· ▟███▄··▄▄▟███▀· ·• ··┃ ·· ·· ·╎ ·· ·• ··╎ ·· ·· · ·╎ ·· ·• ··┃ ·· ·· ·╎ ·· ··• ··╎ ·· ·· ·╎ ·· ·]8;;]8;;]8;; ·▜█▛▀▘ ▜█▛▀▘ · · · · • · · · · ··· · • · · · · · · • · · · · · • · · · ]8;;]8;;]8;; ]8;;]8;;]8;; Welcome to PRIME Agent ]8;;]8;;]8;; ]8;;]8;;]8;; Share agent traces with Prime Intellect? ]8;;]8;;]8;; ]8;;]8;;]8;; Trace sharing helps us train better open-source ]8;;]8;;]8;; models and improve the open agent ecosystem for ]8;;]8;;]8;; everyone. ]8;;]8;;]8;; ]8;;]8;;]8;; > Share  ]8;;]8;;]8;;  Not now  ]8;;]8;;]8;; ]8;;]8;;]8;; You can change this anytime with /traces. ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;]8;; ]8;;]8;;[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/prime-agent-0-9-8-turn-ended.meta.json b/src/main/runtime/__fixtures__/prime-agent-0-9-8-turn-ended.meta.json new file mode 100644 index 000000000000..4b772a30501b --- /dev/null +++ b/src/main/runtime/__fixtures__/prime-agent-0-9-8-turn-ended.meta.json @@ -0,0 +1,15 @@ +{ + "capturedAt": "2026-09-30T19:19:59.244Z", + "platform": "darwin", + "command": [ + "prime-agent", + "--provider", + "openrouter", + "--model", + "inclusionai/ling-3.0-flash-sante:free" + ], + "cols": 120, + "rows": 40, + "note": "prime-agent 0.9.8, macOS, OpenRouter inclusionai/ling-3.0-flash-sante:free, isolated HOME, one short turn, recording stopped after it ended", + "exitCode": 129 +} diff --git a/src/main/runtime/__fixtures__/prime-agent-0-9-8-turn-ended.txt b/src/main/runtime/__fixtures__/prime-agent-0-9-8-turn-ended.txt new file mode 100644 index 000000000000..3c2a911d2bc0 --- /dev/null +++ b/src/main/runtime/__fixtures__/prime-agent-0-9-8-turn-ended.txt @@ -0,0 +1 @@ +[?2004h]10;?\]11;?\[?u[?25l[?1049h[?25l[?1002h[?1006h[?2026h ws]8;;]8;;  ▗▄▄█▀ ]8;;  ███▄ ▗▄███▀ ]8;;  ▗█▛▐█▙ ▗▄█▀▗█▀ prime agent v0.9.8 ]8;;  ▗█▛ ▟██▙▄██▛ ▟▛ model — ]8;;  ▗▟▌ ▐███▛▘▗▄█▖ cwd ~/ws ]8;; ▟███▄ ▄▄▟███▀ ]8;; ▜█▛▀▘ ▜█▛▀▘ ]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;]8;;  Details mode (Ctrl+O to expand) ]8;; ]8;; >   ]8;; ]8;;← manage ]8;;[?2026l[?25l]0;prime-agent - ws[?2026h ws $0.00]8;;  ▗█▛ ▟██▙▄██▛ ▟▛ model inclusionai/ling-3.0-flash-sante:free ]8;;[Skill warning] ]8;; auto (project) /Users/xxxxxx/.agents/skills/orchestration/SKILL.md ]8;; description exceeds 1024 characters (1038) ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 0 (0%)]8;;[?2026l[?25l[?2026h[?2026l[?25l[>4;2m[?2026h > Without using any tools, reply with the word ok.  ]8;;[?2026l[?25l[?2026h >   ]8;;[?2026l[?25l[?2026h]133;A ]8;; Without using any tools, reply with the word ok. ]8;;]133;B]133;C ]8;; ⠋ Waiting · 0s ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠙ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠹ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠸ Waiting · 0s ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠼ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠴ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠦ Waiting · 0s ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠧ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠇ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠏ Waiting · 0s ]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠋ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠙ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠹ Waiting · 0s ]8;;[?2026l[?25l[?2026h ⠹ Waiting · 1s ]8;;[?2026l[?25l[?2026h]133;A]8;;]133;B]133;C The user ]8;; ⠹ Thinking · 1s ]8;;[?2026l[?25l[?2026h]133;B]133;C The user wants me to reply with the word "ok" without ]8;; ⠹ Thinking · 1s · ↓ 13 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 13 (0%)]8;;[?2026l[?25l[?2026h]133;B]133;C The user wants me to reply with the word "ok" without using any tools. ]8;; ⠸ Thinking · 1s · ↓ 18 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 18 (0%)]8;;[?2026l[?25l[?2026h The user wants me to reply with the word "ok" without using any tools. ]8;;]133;B]133;C ok ]8;; ⠸ Writing · 1s · ↓ 18 tokens ]8;;[?2026l[?25l[?2026h ⠸ Waiting · 1s · ↑ 20 tokens ]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 20 (0%)]8;;[?2026l[?25l[?2026h[?2026l[?25l[?2026h ⠼ Waiting · 1s · ↑ 20 tokens ]8;;[?2026l[?25l[?2026h]8;;← manage inclusionai/ling-3.0-flash-sante:free:high · 8.1k (3%)]8;;[?2026l[?25l[?2026h[?2026l[?25l \ No newline at end of file diff --git a/src/main/runtime/agent-session-backup-recovery-fence.ts b/src/main/runtime/agent-session-backup-recovery-fence.ts index dc0fd835fa89..adbdf162cccf 100644 --- a/src/main/runtime/agent-session-backup-recovery-fence.ts +++ b/src/main/runtime/agent-session-backup-recovery-fence.ts @@ -1,14 +1,14 @@ -// Recovering the agent-session store from its backup, without minting a second writer. +// Importing the records file's backup, without minting a second writer. Only the one-time import +// into the chat database (agent-session-legacy-record-import.ts) applies this now. // // The backup is the previous committed generation. The commit that never landed may have granted a // fence chosen by `nextAgentSessionFence` from the backup lease, and // `isAgentSessionFenceCurrent` compares with STRICT EQUALITY. That choice may already be above // `runtimeFence + 1` after an earlier recovery; the new floor must strictly dominate it. // -// The bound is one lost mint per backup generation: each mint site uses -// `nextAgentSessionFence` once per transaction, and the save path aborts rather than advancing the -// primary past a stale backup. A source-level ratchet rejects direct `+ 1` mints; an indirected -// mint is not caught. +// The bound is one lost mint per backup generation: each mint site used `nextAgentSessionFence` +// once per transaction, and the file's save path aborted rather than advancing the primary past a +// stale backup. A source-level ratchet rejects direct `+ 1` mints; an indirected mint is not caught. // // This records a FLOOR for the next grant and leaves the current fence alone. Rewriting the current // fence is what an earlier version did, and it corrupted exactly the records it meant to save: a @@ -19,8 +19,7 @@ // Ownership is deliberately untouched. `claimStatus` (a conflict must survive restart), // `ownerProcess` (the identity evidence the owner probe needs — the lease owner is a child process // that can outlive a main-process crash) and `handoffStage` all carry forward verbatim. Loading -// already marks every lease unreconciled, and the restart reconciler re-adjudicates them by probe -// once transactions are admitted. Nulling that evidence is how you get two writers on one provider +// marks every lease unreconciled, and the restart reconciler re-adjudicates them by probe. Nulling that evidence is how you get two writers on one provider // session; the fence protects the store, not the provider session. import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' diff --git a/src/main/runtime/agent-session-backup-recovery.test.ts b/src/main/runtime/agent-session-backup-recovery.test.ts deleted file mode 100644 index 3c6ac2dcecf8..000000000000 --- a/src/main/runtime/agent-session-backup-recovery.test.ts +++ /dev/null @@ -1,420 +0,0 @@ -import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { evaluateAgentSessionAcquisition } from '../../shared/agent-session-lease-adjudication' -import { isPersistedAgentSessionRecord } from '../../shared/agent-session-record' -import { agentSessionLeaseFixture } from '../../shared/agent-session-record.test-fixture' -import { AgentSessionRecordStore } from './agent-session-record-store' -import { - agentSessionStorePath, - loadAgentSessionStore, - saveAgentSessionStore -} from './agent-session-record-store-file' - -/** Reserve, observe the spawn, prove the handle: the only path to a `live` lease, whose invariant - * is that the head handle was minted at exactly the current fence. One store instance throughout, - * because reopening marks every lease unreconciled and refuses the next two steps. */ -async function seedLiveSession(sessionId: string): Promise { - const store = await openStore() - const reserved = await store.reserveOwner({ - sessionId, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' - }, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, - expectedFence: null, - spawnToken: 'seed-live', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' }, - operation: { callerKey: 'test', operationId: operationId(), fingerprint: 'seed-live' }, - now: NOW - }) - const fence = reserved.record.lease.runtimeFence - await store.commitProcessIdentity({ - sessionId, - fence, - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW, - spawnToken: 'seed-live' - }, - now: NOW - }) - await store.proveOwner({ - sessionId, - fence, - link: { - linkId: 'link-live-1', - origin: 'created', - mintedAtFence: fence, - observedAt: NOW, - handle: { provider: 'codex', threadId: `thread-${sessionId}` } - }, - now: NOW - }) - return fence -} - -let root: string -let storePath: string - -const NOW = 1_800_000_000_000 - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-store-recovery-')) - storePath = agentSessionStorePath(root) - operations = 0 -}) - -afterEach(async () => { - await rm(root, { recursive: true, force: true }) -}) - -async function openStore(): Promise { - return AgentSessionRecordStore.open({ directory: root, hostId: 'local' }) -} - -let operations = 0 - -function operationId(): string { - operations += 1 - return `${NOW}-${operations.toString(16).padStart(32, '0')}` -} - -async function seedSession(sessionId: string): Promise { - const store = await openStore() - const reserved = await store.reserveOwner({ - sessionId, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' - }, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, - expectedFence: null, - spawnToken: 'seed', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' }, - operation: { callerKey: 'test', operationId: operationId(), fingerprint: 'seed' }, - now: NOW - }) - return reserved.record.lease.runtimeFence -} - -describe('crash-safe store writes', () => { - it('never leaves the live path absent, and keeps a whole backup', async () => { - await seedSession('session-a') - const afterFirst = await readFile(storePath, 'utf-8') - expect(JSON.parse(afterFirst)).toBeTruthy() - - await seedSession('session-b') - // Both candidates parse: the previous generation was COPIED aside, not moved. - expect(JSON.parse(await readFile(storePath, 'utf-8'))).toBeTruthy() - expect(JSON.parse(await readFile(`${storePath}.bak`, 'utf-8'))).toBeTruthy() - }) - - it('leaves no orphaned temp file behind', async () => { - await seedSession('session-a') - await seedSession('session-b') - const { readdir } = await import('node:fs/promises') - expect((await readdir(root)).filter((name) => name.endsWith('.tmp'))).toEqual([]) - }) - - it('aborts the save rather than advancing the primary past a stale backup', async () => { - await seedSession('session-a') - const committed = await readFile(storePath, 'utf-8') - const loaded = await loadAgentSessionStore(storePath, 'local') - // A directory in the backup's place makes the rotation fail the way a full or read-only - // disk would. The save must not publish a primary the backup can no longer match. - await rm(`${storePath}.bak`, { force: true }) - const { mkdir } = await import('node:fs/promises') - await mkdir(`${storePath}.bak`) - - await expect( - saveAgentSessionStore(storePath, loaded.state, { - primaryStatus: 'validated' - }) - ).rejects.toBeTruthy() - expect(await readFile(storePath, 'utf-8')).toBe(committed) - expect((await stat(`${storePath}.bak`)).isDirectory()).toBe(true) - }) -}) - -describe('recovery from the committed backup', () => { - it('completes the next transaction instead of refusing forever', async () => { - await seedSession('session-a') - await seedSession('session-b') - // The exact shape a real profile wedged in: backup only, no live file. - await rm(storePath, { force: true }) - - await expect(seedSession('session-c')).resolves.toBeGreaterThan(0) - expect(JSON.parse(await readFile(storePath, 'utf-8'))).toBeTruthy() - }) - - it('never grants the fence the lost commit could already have handed out', async () => { - const fence = await seedSession('session-a') - // A second commit is what produces the backup; the first write has nothing to rotate. - await seedSession('session-b') - await rm(storePath, { force: true }) - - const store = await openStore() - // The floor lands in the first transaction after recovery, not at load. - await store.retireClaimKey(`retire-${operationId()}`, NOW) - const record = store.getRecord('session-a') - expect(record).toBeTruthy() - - // The lost commit could have granted fence + 1. Adjudication must skip it, or two writers - // end up holding the same number under strict-equality comparison. - const granted = evaluateAgentSessionAcquisition({ - lease: { ...record!.lease, unreconciled: false, ownerProcess: null, claimStatus: 'released' }, - expectedFence: record!.lease.runtimeFence, - handoffOperationId: null, - probe: { outcome: 'reservation-unused' } - }) - expect(granted.decision).toBe('granted') - expect(granted.decision === 'granted' && granted.nextFence).toBeGreaterThan(fence + 1) - }) - - it('does not reissue a grant after two backup fallbacks and a backup rotation', async () => { - await seedSession('session-a') - await seedSession('session-b') - const loaded = await loadAgentSessionStore(storePath, 'local') - const record = loaded.state.records.get('session-a') - if (!record) { - throw new Error('seeded session missing') - } - loaded.state.records.set('session-a', { - ...record, - lease: { - ...record.lease, - runtimeFence: 7, - claimStatus: 'released', - handoffStage: null, - reservedSpawnToken: null - } - }) - // Two commits put the prepared generation in the backup, just as normal rotation would. - await saveAgentSessionStore(storePath, loaded.state, { primaryStatus: 'validated' }) - await saveAgentSessionStore(storePath, loaded.state, { primaryStatus: 'validated' }) - const identity = { - location: record.location, - provider: record.provider, - accountHome: record.accountHome, - runtimeKind: record.lease.runtimeKind, - claimKeyId: record.lease.claimKeyId - } - - await rm(storePath, { force: true }) - const first = await openStore() - await first.retireClaimKey(`retire-${operationId()}`, NOW) - await first.reconcileOnRestart({ - probe: async () => ({ outcome: 'reservation-unused' }), - now: NOW - }) - expect(first.getRecord('session-a')?.lease).toMatchObject({ - runtimeFence: 7, - minimumNextFence: 9, - unreconciled: false - }) - const firstGrant = await first.reserveOwner({ - ...identity, - sessionId: 'session-a', - expectedFence: 7, - spawnToken: 'first-recovery', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' }, - operation: { callerKey: 'test', operationId: operationId(), fingerprint: 'first-recovery' }, - now: NOW - }) - const firstFence = firstGrant.record.lease.runtimeFence - const rotated = (await loadAgentSessionStore(`${storePath}.bak`, 'local')).state.records.get( - 'session-a' - ) - expect(rotated?.lease).toMatchObject({ runtimeFence: 7, minimumNextFence: 9 }) - - // The primary's grant is lost, but its owner may still hold that exact fence. - await rm(storePath, { force: true }) - const second = await openStore() - await second.retireClaimKey(`retire-${operationId()}`, NOW) - await second.reconcileOnRestart({ - probe: async () => ({ outcome: 'reservation-unused' }), - now: NOW - }) - const secondGrant = await second.reserveOwner({ - ...identity, - sessionId: 'session-a', - expectedFence: 7, - spawnToken: 'second-recovery', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' }, - operation: { callerKey: 'test', operationId: operationId(), fingerprint: 'second-recovery' }, - now: NOW - }) - expect(secondGrant.record.lease.runtimeFence).toBeGreaterThan(firstFence) - expect((await openStore()).getRecord('session-a')?.lease.runtimeFence).toBe( - secondGrant.record.lease.runtimeFence - ) - }) - - it.each([ - [Number.MAX_SAFE_INTEGER - 2, Number.MAX_SAFE_INTEGER], - [Number.MAX_SAFE_INTEGER - 1, null] - ])('keeps the recovered floor safe at fence %i', async (runtimeFence, expectedFloor) => { - await seedSession('session-a') - await seedSession('session-b') - const backupPath = `${storePath}.bak` - const backup = JSON.parse(await readFile(backupPath, 'utf-8')) - backup.records['session-a'].lease.runtimeFence = runtimeFence - await writeFile(backupPath, JSON.stringify(backup)) - await rm(storePath, { force: true }) - - const recovered = await openStore() - if (expectedFloor === null) { - await expect(recovered.retireClaimKey(`retire-${operationId()}`, NOW)).rejects.toThrow( - 'agent_session_fence_exhausted' - ) - await expect(stat(storePath)).rejects.toMatchObject({ code: 'ENOENT' }) - const preserved = (await loadAgentSessionStore(backupPath, 'local')).state.records.get( - 'session-a' - )?.lease - expect(preserved?.runtimeFence).toBe(runtimeFence) - expect(preserved?.minimumNextFence).toBeUndefined() - } else { - await recovered.retireClaimKey(`retire-${operationId()}`, NOW) - expect(recovered.getRecord('session-a')?.lease).toMatchObject({ - runtimeFence, - minimumNextFence: expectedFloor - }) - expect((await openStore()).getRecord('session-a')?.lease.minimumNextFence).toBe(expectedFloor) - } - }) - - it('leaves recovered records valid, so the next load does not quarantine them', async () => { - await seedLiveSession('session-a') - await seedSession('session-b') - await rm(storePath, { force: true }) - - const store = await openStore() - await store.retireClaimKey(`retire-${operationId()}`, NOW) - const record = store.getRecord('session-a') - expect(record?.lease.claimStatus).toBe('live') - // A `live` lease means a provider handle proven at exactly lease.runtimeFence. Recovery that - // rewrote the fence broke that, so the record failed validation, was quarantined on the next - // load, and dropped straight back to the same backup. - expect(isPersistedAgentSessionRecord(record)).toBe(true) - }) - - it('carries ownership evidence forward verbatim', async () => { - await seedSession('session-a') - await seedSession('session-b') - const before = (await loadAgentSessionStore(`${storePath}.bak`, 'local')).state.records.get( - 'session-a' - )! - await rm(storePath, { force: true }) - - const store = await openStore() - await store.retireClaimKey(`retire-${operationId()}`, NOW) - const after = store.getRecord('session-a')! - expect(after.lease.claimStatus).toBe(before.lease.claimStatus) - expect(after.lease.ownerProcess).toEqual(before.lease.ownerProcess) - expect(after.lease.handoffStage).toBe(before.lease.handoffStage) - // "Not currently owned" is expressed by unreconciled, not by erasing the evidence. - expect(after.lease.unreconciled).toBe(true) - }) - - // Recovery restores the previous COMMITTED generation; the lost commit is lost by definition. - // What must not happen is reconciliation dropping anything the backup did hold. - it('drops nothing the committed backup held', async () => { - await seedSession('session-a') - await seedSession('session-b') - const before = (await loadAgentSessionStore(`${storePath}.bak`, 'local')).state - await rm(storePath, { force: true }) - - const store = await openStore() - await store.retireClaimKey(`retire-${operationId()}`, NOW) - const after = (await loadAgentSessionStore(storePath, 'local')).state - expect([...after.records.keys()].sort()).toEqual([...before.records.keys()].sort()) - expect(after.operations.size).toBe(before.operations.size) - // Everything the backup held is still there; the transaction that drove recovery adds its own. - for (const key of before.retiredClaimKeys) { - expect(after.retiredClaimKeys).toContainEqual(key) - } - expect([...after.unreadableRecords.keys()]).toEqual([...before.unreadableRecords.keys()]) - }) -}) - -describe('a transient primary read failure is not recovery', () => { - it('refuses rather than falling back to a usable but older backup', async () => { - await seedSession('session-a') - // The second commit leaves a VALID backup, so a fallback would silently succeed with - // older state — the failure this guard exists to prevent. - await seedSession('session-b') - expect( - (await loadAgentSessionStore(`${storePath}.bak`, 'local')).state.records.has('session-a') - ).toBe(true) - - // A directory at the primary path fails the read with EISDIR, not ENOENT — the shape of a - // permission or IO fault. It says nothing about the primary's contents. - await rm(storePath, { force: true }) - const { mkdir } = await import('node:fs/promises') - await mkdir(storePath) - - await expect(loadAgentSessionStore(storePath, 'local')).rejects.toThrow( - 'agent_session_store_corrupt' - ) - }) -}) - -describe('the fence-step bound the +2 floor rests on', () => { - it('advances a session fence by exactly one per grant when no floor is set', () => { - const granted = evaluateAgentSessionAcquisition({ - lease: agentSessionLeaseFixture({ - runtimeFence: 7, - claimStatus: 'released', - ownerProcess: null, - provenHandleLinkId: null - }), - expectedFence: 7, - handoffOperationId: null, - probe: { outcome: 'reservation-unused' } - }) - // If a grant could ever advance by more than one, the +2 recovery floor would stop dominating - // the highest fence a single lost commit can have handed out. - expect(granted.decision === 'granted' && granted.nextFence).toBe(8) - }) - - it('honours a recovery floor that sits above the next step', () => { - const granted = evaluateAgentSessionAcquisition({ - lease: agentSessionLeaseFixture({ - runtimeFence: 7, - minimumNextFence: 9, - claimStatus: 'released', - ownerProcess: null, - provenHandleLinkId: null - }), - expectedFence: 7, - handoffOperationId: null, - probe: { outcome: 'reservation-unused' } - }) - expect(granted.decision === 'granted' && granted.nextFence).toBe(9) - }) -}) - -describe('a store that never existed', () => { - it('does not claim recovery', async () => { - await writeFile(join(root, 'unrelated.txt'), 'x', 'utf-8') - const loaded = await loadAgentSessionStore(storePath, 'local') - expect(loaded.storeFound).toBe(false) - expect(loaded.recoveredFromBackup).toBe(false) - }) -}) diff --git a/src/main/runtime/agent-session-conversation-clear-commit.test.ts b/src/main/runtime/agent-session-conversation-clear-commit.test.ts new file mode 100644 index 000000000000..3e7c63a41c34 --- /dev/null +++ b/src/main/runtime/agent-session-conversation-clear-commit.test.ts @@ -0,0 +1,162 @@ +// A /clear's one store write: the conversation it continues in, founded at rest, and the marker +// on the cleared record that points there. Either both land, on disk too, or neither does. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import { isAgentSessionRefusalError } from '../../shared/agent-session-wire-refusals' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' + +const NOW = 1_800_000_000_000 +const SOURCE = 'session-alpha' +const REPLACEMENT = `clear-${'a'.repeat(40)}` +const INDETERMINATE: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'no answer' } + +let directory: string + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-clear-commit-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +const open = () => openTestAgentSessionRecordStore(directory) + +async function storeWithSource(): Promise { + const store = await open() + await store.reserveOwner({ + sessionId: SOURCE, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, + launchArgs: ['--flag'], + options: { model: 'sonnet' }, + expectedFence: null, + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: INDETERMINATE, + operation: { + callerKey: 'client-1', + operationId: `${NOW}-${'1'.repeat(32)}`, + fingerprint: 'fp' + }, + now: NOW + }) + await store.setSessionTabVisibility(SOURCE, true, 'tab-alpha') + return store +} + +function marker(fence: number, replacementSessionId = REPLACEMENT) { + return { + command: 'clear' as const, + state: 'completed' as const, + phase: 'committed' as const, + runtimeFence: fence, + operationId: `${NOW}-${'2'.repeat(32)}`, + callerKey: 'client-1', + replacementSessionId + } +} + +describe("a /clear's commit", () => { + it('founds the replacement at rest and points the cleared record at it, on disk', async () => { + const store = await storeWithSource() + const fence = store.getRecord(SOURCE)!.lease.runtimeFence + await store.commitConversationClear({ + sessionId: SOURCE, + fence, + command: marker(fence), + claimKeyId: 'key-1', + now: NOW + 5 + }) + + const reopened = await open() + const source = reopened.getRecord(SOURCE)! + expect(source.conversationCommand).toEqual(marker(fence)) + expect(reopened.getRecord(REPLACEMENT)).toEqual({ + schemaVersion: source.schemaVersion, + sessionId: REPLACEMENT, + location: source.location, + provider: 'claude', + accountHome: source.accountHome, + options: { model: 'sonnet' }, + launchArgs: ['--flag'], + providerHandleChain: [], + createdAt: NOW + 5, + updatedAt: NOW + 5, + lease: { + sessionId: REPLACEMENT, + runtimeKind: 'native', + runtimeFence: 1, + handoffStage: null, + provenHandleLinkId: null, + ownerProcess: null, + reservedSpawnToken: null, + leaseDeadlineAt: NOW + 5, + lastRenewedAt: NOW + 5, + handoffOperationId: null, + journalCheckpoint: null, + claimKeyId: 'key-1', + claimStatus: 'released', + // Loading marks every lease for this host's adjudication, as it does any record's. + unreconciled: true, + deathEvidence: null + } + }) + expect(reopened.getSessionTabId(REPLACEMENT)).toBe('tab-alpha') + expect(reopened.getSessionTabId(SOURCE)).toBeNull() + }) + + it('writes neither the replacement nor the marker when the commit is refused', async () => { + const store = await storeWithSource() + const fence = store.getRecord(SOURCE)!.lease.runtimeFence + // The source's lease moved after the clear read it: the marker's fenced write refuses. + const refused = await store + .commitConversationClear({ + sessionId: SOURCE, + fence: fence + 1, + command: marker(fence + 1), + claimKeyId: 'key-1', + now: NOW + }) + .catch((error: unknown) => error) + expect(isAgentSessionRefusalError(refused)).toBe(true) + + for (const each of [store, await open()]) { + expect(each.getRecord(REPLACEMENT)).toBeNull() + expect(each.getRecord(SOURCE)?.conversationCommand).toBeUndefined() + expect(each.getSessionTabId(SOURCE)).toBe('tab-alpha') + expect(each.listRecords().map((record) => record.sessionId)).toEqual([SOURCE]) + } + }) + + it('never overwrites a record already under the replacement id', async () => { + const store = await storeWithSource() + const fence = store.getRecord(SOURCE)!.lease.runtimeFence + const before = store.getRecord(SOURCE)! + const refused = await store + .commitConversationClear({ + sessionId: SOURCE, + fence, + command: marker(fence, SOURCE), + claimKeyId: 'key-1', + now: NOW + }) + .catch((error: unknown) => error) + expect(refused).toMatchObject({ + refusal: { code: 'agent_session_conflict', details: { reason: 'sessionExists' } } + }) + expect(store.getRecord(SOURCE)).toEqual(before) + }) +}) diff --git a/src/main/runtime/agent-session-conversation-command-record.ts b/src/main/runtime/agent-session-conversation-command-record.ts index 297b9dd34e73..4aefce22d577 100644 --- a/src/main/runtime/agent-session-conversation-command-record.ts +++ b/src/main/runtime/agent-session-conversation-command-record.ts @@ -1,6 +1,7 @@ import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import type { AgentSessionStoreState } from './agent-session-record-store-file' import { AgentSessionTabTable } from './agent-session-tab-table' +import { foundAgentSessionRecord } from './agent-session-record-founding' import type { AgentSessionConversationCommandRecord } from '../../shared/agent-session-conversation-command' export function commitConversationCommandRecord( @@ -26,3 +27,32 @@ export function commitConversationCommandRecord( state.sessionTabs.move(sessionId, command.replacementSessionId) } } + +export type AgentSessionConversationClear = { + sessionId: string + fence: number + command: AgentSessionConversationCommandRecord & { replacementSessionId: string } + claimKeyId: string + now: number +} + +/** A /clear as one write: the conversation it continues in, founded from the source's identity, + * and the marker pointing there. Neither can land without the other. */ +export function commitConversationClearRecord( + state: AgentSessionStoreState, + clear: AgentSessionConversationClear +): void { + const source = state.records.get(clear.sessionId) + const replacementId = clear.command.replacementSessionId + if (!source) { + throw agentSessionRefusalError('agent_session_checkpoint_stale', { reason: 'leaseMoved' }) + } + if (state.records.has(replacementId) || state.unreadableRecords.has(replacementId)) { + throw agentSessionRefusalError('agent_session_conflict', { reason: 'sessionExists' }) + } + state.records.set( + replacementId, + foundAgentSessionRecord({ ...source, sessionId: replacementId }, clear) + ) + commitConversationCommandRecord(state, clear.sessionId, clear.fence, clear.command) +} diff --git a/src/main/runtime/agent-session-lease-transitions.ts b/src/main/runtime/agent-session-lease-transitions.ts index 41e8d300e1e9..0b5b6e5f2efd 100644 --- a/src/main/runtime/agent-session-lease-transitions.ts +++ b/src/main/runtime/agent-session-lease-transitions.ts @@ -19,7 +19,6 @@ import { import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' import type { AgentSessionDeathEvidence, - AgentSessionJournalCheckpoint, AgentSessionLease, AgentSessionProcessIdentity, AgentSessionRecord @@ -270,26 +269,3 @@ function releasedAgentSessionLease( deathEvidence }) } - -export function setAgentSessionJournalCheckpoint(args: { - record: AgentSessionRecord - fence: number - checkpoint: AgentSessionJournalCheckpoint - now: number -}): AgentSessionRecord { - const { record } = args - assertFence(record.lease, args.fence) - const current = record.lease.journalCheckpoint - if ( - current && - (current.epoch > args.checkpoint.epoch || - (current.epoch === args.checkpoint.epoch && current.sequence > args.checkpoint.sequence)) - ) { - throw new Error('agent_session_checkpoint_stale') - } - return withLease(record, { - ...record.lease, - journalCheckpoint: args.checkpoint, - lastRenewedAt: args.now - }) -} diff --git a/src/main/runtime/agent-session-legacy-record-import.test.ts b/src/main/runtime/agent-session-legacy-record-import.test.ts new file mode 100644 index 000000000000..da09cb2257d8 --- /dev/null +++ b/src/main/runtime/agent-session-legacy-record-import.test.ts @@ -0,0 +1,440 @@ +// The one-time copy of `agent-sessions.json` into the chat journal database: what it copies, what it +// leaves untouched, and how each fence stays clear of anything the file's writers could have granted. + +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' +import { + agentSessionOperationKey, + type AgentSessionOperationRow +} from '../../shared/agent-session-operation-ledger' +import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { journalPragmaNumber } from '../native-chat/agent-session-journal/journal-database' +import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import type { LegacyAgentSessionRecordImportReport } from './agent-session-legacy-record-import' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { legacyAgentSessionStorePath } from './agent-session-record-store-file' +import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' + +const NOW = 1_800_000_000_000 +const ALPHA = 'session-alpha' +const BETA = 'session-beta' +const OPERATION_ID = `${NOW}-000000000000000000000000000000aa` +const LEGACY_TAB_ID = `structured-agent-session-${ALPHA}` + +let root: string +const opened: JournalHostDatabase[] = [] + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-legacy-record-import-')) +}) + +afterEach(async () => { + opened.splice(0).forEach((database) => database.close()) + await rm(root, { recursive: true, force: true }) +}) + +const legacyPath = (): string => legacyAgentSessionStorePath(root) + +/** A released chat: no owner a restart probe must look for. */ +function record(sessionId: string, lease: Partial = {}): AgentSessionRecord { + return agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId, + runtimeFence: 2, + ownerProcess: null, + reservedSpawnToken: null, + claimStatus: 'released', + ...lease + }) + ) +} + +const OPERATION: AgentSessionOperationRow = { + callerKey: 'client-1', + operationId: OPERATION_ID, + fingerprint: 'fp-1', + operationTimestamp: NOW, + recordedAt: NOW, + expiresAt: NOW + 60_000, + outcome: { status: 'succeeded', sessionId: ALPHA } +} + +function legacyFile( + records: readonly (Record & { sessionId: string })[], + extra: Record = {} +): Record { + return { + schemaVersion: 2, + hostId: 'local', + records: Object.fromEntries(records.map((value) => [value.sessionId, value])), + operations: {}, + retiredClaimKeys: [], + unusableRecords: {}, + ...extra + } +} + +async function writeLegacy(file: unknown, backup?: unknown): Promise { + await mkdir(dirname(legacyPath()), { recursive: true }) + await writeFile(legacyPath(), typeof file === 'string' ? file : JSON.stringify(file)) + if (backup !== undefined) { + await writeFile( + `${legacyPath()}.bak`, + typeof backup === 'string' ? backup : JSON.stringify(backup) + ) + } +} + +/** What an install does: open the database, running the copy if it is owed, then the store. */ +async function install(): Promise<{ + database: JournalHostDatabase + store: AgentSessionRecordStore + reports: LegacyAgentSessionRecordImportReport[] +}> { + const reports: LegacyAgentSessionRecordImportReport[] = [] + const database = await openStructuredAgentSessionJournalDatabase({ + stateDirectory: root, + hostId: 'local', + onLegacyRecordImportReport: (report) => reports.push(report) + }) + opened.push(database) + return { + database, + store: AgentSessionRecordStore.open({ journalDatabase: database, hostId: 'local' }), + reports + } +} + +function unreconciled(value: AgentSessionRecord): AgentSessionRecord { + return { ...value, lease: { ...value.lease, unreconciled: true } } +} + +describe('copying the records file into the chat database', () => { + it('copies every record, operation, key and tab, and leaves both files byte-identical', async () => { + const alpha = record(ALPHA) + const beta = record(BETA, { runtimeFence: 5 }) + const file = legacyFile([alpha, beta], { + operations: { [agentSessionOperationKey('client-1', OPERATION_ID)]: OPERATION }, + retiredClaimKeys: [{ keyId: 'key-0', retiredAt: NOW }], + sessionTabs: [{ tabId: 'tab-beta', sessionId: BETA }] + }) + await writeLegacy(file, file) + const [primary, backup] = await Promise.all([ + readFile(legacyPath()), + readFile(`${legacyPath()}.bak`) + ]) + + const { database, store, reports } = await install() + + expect(reports).toEqual([]) + expect(journalPragmaNumber(database.db, 'user_version')).toBe(4) + expect(store.getRecord(ALPHA)).toEqual(unreconciled(alpha)) + expect(store.getRecord(BETA)).toEqual(unreconciled(beta)) + expect(store.listOperationRows()).toEqual([OPERATION]) + expect(store.isClaimKeyVerifiable('key-0', NOW)).toBe(true) + expect(store.getVisibleSessionTabIndex()).toEqual({ present: true, sessionIds: [BETA] }) + expect(store.getSessionTabId(BETA)).toBe('tab-beta') + expect(await readFile(legacyPath())).toEqual(primary) + expect(await readFile(`${legacyPath()}.bak`)).toEqual(backup) + }) + + it('copies once: a later launch never reads the file again', async () => { + await writeLegacy(legacyFile([record(ALPHA)])) + const first = await install() + await first.store.setConversationName(ALPHA, 'kept') + first.database.close() + // A downgraded build changed its own file; the database keeps what it holds. + await writeLegacy(legacyFile([record(BETA)])) + + const { store } = await install() + + expect(store.getRecord(ALPHA)?.conversationName).toBe('kept') + expect(store.getRecord(BETA)).toBeNull() + }) + + it('reports a file no read will make usable, copies nothing, and leaves it untouched', async () => { + const adHoc = JSON.stringify({ ...legacyFile([record(ALPHA)]), schemaVersion: 1 }) + await writeLegacy(adHoc) + + const { database, store, reports } = await install() + + expect(reports).toEqual([ + { + kind: 'unusable', + error: expect.objectContaining({ message: 'agent_session_store_corrupt' }) + } + ]) + expect(journalPragmaNumber(database.db, 'user_version')).toBe(4) + expect(store.listRecords()).toEqual([]) + expect(await readFile(legacyPath(), 'utf-8')).toBe(adHoc) + }) + + it('reports a file from another host id and copies it, so its owners stay unresolved', async () => { + await writeLegacy({ ...legacyFile([record(ALPHA)]), hostId: 'ssh:elsewhere' }) + + const { store, reports } = await install() + + expect(reports).toEqual([ + { kind: 'host-mismatch', fileHostId: 'ssh:elsewhere', hostId: 'local' } + ]) + expect(store.hostId).toBe('local') + expect(store.getRecord(ALPHA)).not.toBeNull() + }) + + it('maps a lease the removed terminal handoff wrote, and stores it mapped', async () => { + const legacy = { + ...record(ALPHA, { + ownerProcess: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'spawn-a' } + }), + lease: { + ...record(ALPHA).lease, + ownerProcess: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'spawn-a' }, + claimStatus: 'live', + runtimeKind: 'tui', + handoffStage: 'old-owner-stopped', + handoffOperationId: 'op-handoff', + processlessAt: 5 + } + } + await writeLegacy(legacyFile([legacy])) + + const { database, store } = await install() + + expect(store.isSessionUnreadable(ALPHA)).toBe(false) + const lease = store.getRecord(ALPHA)?.lease + expect(lease).toMatchObject({ + runtimeKind: 'native', + handoffStage: 'recovering', + handoffOperationId: 'op-handoff', + claimStatus: 'conflicted' + }) + expect(lease).not.toHaveProperty('processlessAt') + const stored = database.db + .prepare('SELECT record_json FROM agent_session_records WHERE session_id = ?') + .get(ALPHA) + expect(JSON.parse(String(stored?.record_json)).lease).toMatchObject({ + runtimeKind: 'native', + handoffStage: 'recovering' + }) + expect(JSON.parse(String(stored?.record_json)).lease).not.toHaveProperty('processlessAt') + }) +}) + +describe('a record the file set aside', () => { + it('copies a record this build cannot read verbatim, and never grants it', async () => { + const unsupported = { ...record(ALPHA), schemaVersion: 1 } + await writeLegacy(legacyFile([unsupported])) + + const { database, store } = await install() + + expect(store.getRecord(ALPHA)).toBeNull() + expect(store.isSessionUnreadable(ALPHA)).toBe(true) + await expect(store.setConversationName(ALPHA, 'x')).rejects.toThrow( + 'execution_owner_reconciling' + ) + const stored = database.db + .prepare('SELECT record_json FROM agent_session_records WHERE session_id = ?') + .get(ALPHA) + expect(JSON.parse(String(stored?.record_json))).toEqual(unsupported) + }) + + // #23589: both copies exist only when an older build recovered the readable one from its backup, + // so the set-aside copy may already have granted fences the readable one cannot show. + it('keeps the readable copy and raises its floor above both: 8, never 3, never 7 twice', async () => { + const readable = record(ALPHA, { runtimeFence: 2 }) + const setAside = { ...record(ALPHA, { runtimeFence: 7 }), schemaVersion: 99 } + await writeLegacy( + legacyFile([readable], { + unusableRecords: { [ALPHA]: { reason: 'unsupported_schema', raw: setAside } } + }) + ) + + const { store } = await install() + + const lease = store.getRecord(ALPHA)?.lease + expect(lease?.runtimeFence).toBe(2) + expect(lease?.minimumNextFence).toBe(8) + expect(nextAgentSessionFence(lease!)).toBe(8) + }) + + it('takes a record the primary set aside from the backup, with its floor raised', async () => { + const readable = record(ALPHA, { runtimeFence: 3 }) + await writeLegacy( + legacyFile([], { + unusableRecords: { [ALPHA]: { reason: 'unsupported_schema', raw: { schemaVersion: 99 } } } + }), + legacyFile([readable]) + ) + + const { store } = await install() + + // The commit the backup lost may have granted 4, so the next grant clears it. + expect(store.getRecord(ALPHA)?.lease.minimumNextFence).toBe(5) + }) +}) + +describe('a primary the backup stands in for', () => { + it('copies the backup with every floor raised past what the lost commit could have granted', async () => { + await writeLegacy('{ truncated', legacyFile([record(ALPHA, { runtimeFence: 1 })])) + + const { store, reports } = await install() + + expect(reports).toEqual([]) + expect(store.getRecord(ALPHA)?.lease).toMatchObject({ runtimeFence: 1, minimumNextFence: 3 }) + expect(await readFile(legacyPath(), 'utf-8')).toBe('{ truncated') + }) + + it('reports both copies unusable and copies nothing', async () => { + await writeLegacy('{ truncated', '{ also truncated') + + const { store, reports } = await install() + + expect(reports).toMatchObject([{ kind: 'unusable' }]) + expect(store.listRecords()).toEqual([]) + }) + + // A read that can clear says nothing about the backup's contents, so the copy stays owed. + it('keeps the copy owed when the backup cannot be read behind a torn primary', async () => { + await writeLegacy('{ truncated') + await mkdir(`${legacyPath()}.bak`) + + const { database, store, reports } = await install() + + expect(reports).toMatchObject([{ kind: 'unavailable' }]) + expect(journalPragmaNumber(database.db, 'user_version')).toBe(3) + expect(store.listRecords()).toEqual([]) + }) + + // Falling back would replace the primary's newer state with the backup's older one for good. + it('never takes a valid backup when the primary cannot be read', async () => { + await mkdir(legacyPath(), { recursive: true }) + await writeFile(`${legacyPath()}.bak`, JSON.stringify(legacyFile([record(ALPHA)]))) + + const { database, store, reports } = await install() + + expect(reports).toMatchObject([{ kind: 'unavailable' }]) + expect(journalPragmaNumber(database.db, 'user_version')).toBe(3) + expect(store.listRecords()).toEqual([]) + }) +}) + +describe('the tab index from before the table', () => { + it('seeds the table from the visible list, keeping each chat on the id it has today', async () => { + const gamma = 'session-gamma' + await writeLegacy( + legacyFile( + [ + { ...record(ALPHA), surfaceTabId: 'tab-alpha' }, + { ...record(BETA), surfaceTabId: `structured-agent-session-${BETA}` }, + record(gamma) + ], + { visibleSessionIds: [ALPHA, BETA] } + ) + ) + + const { store } = await install() + + expect(store.getSessionTabId(ALPHA)).toBe('tab-alpha') + expect(store.getSessionTabId(BETA)).toBe(`structured-agent-session-${BETA}`) + expect(store.getSessionTabId(gamma)).toBeNull() + expect(store.listVisibleSessionIds()).toEqual([ALPHA, BETA]) + }) + + it('seeds a chat cleared before the upgrade under the id its tab opened with', async () => { + const cleared = (replacementSessionId: string) => ({ + command: 'clear', + state: 'completed', + phase: 'committed', + operationId: OPERATION_ID, + callerKey: 'client-1', + replacementSessionId + }) + await writeLegacy( + legacyFile( + [ + { ...record(ALPHA), conversationCommand: cleared('clear-one') }, + { ...record('clear-one'), conversationCommand: cleared('clear-two') }, + { ...record('clear-two'), surfaceTabId: 'structured-agent-session-clear-two' } + ], + { visibleSessionIds: [ALPHA, 'clear-two'] } + ) + ) + + const { store } = await install() + + expect(store.getSessionTabId('clear-two')).toBe(LEGACY_TAB_ID) + const reopenedTab = store.getSessionTabId(ALPHA) + expect(reopenedTab).not.toBe(LEGACY_TAB_ID) + expect(reopenedTab).not.toContain(':') + expect(store.listVisibleSessionIds()).toEqual([ALPHA, 'clear-two']) + }) + + it('leaves the index unrecorded when only a chat created while the copy was owed recorded one', async () => { + await mkdir(legacyPath(), { recursive: true }) + const owed = await install() + owed.database.db + .prepare('INSERT INTO agent_session_records (session_id, record_json) VALUES (?, ?)') + .run(BETA, JSON.stringify(record(BETA))) + await AgentSessionRecordStore.open({ + journalDatabase: owed.database, + hostId: 'local' + }).setSessionTabVisibility(BETA, true) + owed.database.close() + await rm(legacyPath(), { recursive: true }) + await writeLegacy(legacyFile([record(ALPHA)])) + + const { database, store } = await install() + + expect(journalPragmaNumber(database.db, 'user_version')).toBe(4) + expect(store.listRecords().map(({ sessionId }) => sessionId)).toEqual([BETA, ALPHA]) + // Restore then takes the profile's tabs, beside the tab the owed-era chat left. + expect(store.getVisibleSessionTabIndex()).toEqual({ present: false, sessionIds: [BETA] }) + }) + + it('reads a recorded table, never the record field', async () => { + await writeLegacy( + legacyFile([{ ...record(ALPHA), surfaceTabId: 'tab-stale' }], { + sessionTabs: [{ tabId: 'tab-alpha', sessionId: ALPHA }] + }) + ) + + expect((await install()).store.getSessionTabId(ALPHA)).toBe('tab-alpha') + }) + + it('keeps a record whose legacy tab id is malformed, seeding it under the derived id', async () => { + await writeLegacy( + legacyFile([{ ...record(ALPHA), surfaceTabId: `agent-session:${ALPHA}` }], { + visibleSessionIds: [ALPHA] + }) + ) + + const { store } = await install() + + expect(store.isSessionUnreadable(ALPHA)).toBe(false) + expect(store.getSessionTabId(ALPHA)).toBe(LEGACY_TAB_ID) + }) + + it('treats a malformed table as an unusable file rather than guessing', async () => { + await writeLegacy( + legacyFile([record(ALPHA)], { + sessionTabs: [ + { tabId: 'tab-alpha', sessionId: ALPHA }, + { tabId: 'tab-alpha', sessionId: BETA } + ] + }) + ) + + const { store, reports } = await install() + + expect(reports).toMatchObject([{ kind: 'unusable' }]) + expect(store.listRecords()).toEqual([]) + expect(store.getVisibleSessionTabIndex().present).toBe(false) + }) +}) diff --git a/src/main/runtime/agent-session-legacy-record-import.ts b/src/main/runtime/agent-session-legacy-record-import.ts new file mode 100644 index 000000000000..0a0b4d49d5bb --- /dev/null +++ b/src/main/runtime/agent-session-legacy-record-import.ts @@ -0,0 +1,161 @@ +// The one-time copy of `agent-sessions.json` into the chat journal database, inside the version-4 +// migration (journal-database.ts). The file and its `.bak` are only ever read: an older build that +// still uses them finds them as it left them. +// +// Never blocks the host. A file no read will make usable is reported, nothing is copied, and the +// migration completes: the file is left untouched, but a later repair of it is never imported. A +// read that can clear leaves the copy owed, and every later launch retries it until one reads the +// file, finds it absent or unusable, or the import is retired. + +import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { + NO_LEGACY_JOURNAL_RECORDS, + type JournalLegacyRecordImport +} from '../native-chat/agent-session-journal/journal-database' +import { raiseAgentSessionFencesAfterBackupRecovery } from './agent-session-backup-recovery-fence' +import { + legacyAgentSessionStorePath, + loadAgentSessionStore, + type AgentSessionStoreState +} from './agent-session-record-store-file' +import { + insertAgentSessionStoreRowsIfAbsent, + withoutRetiredLeaseLatches, + type AgentSessionStoreImportRows +} from './agent-session-record-rows' +import { isReadableAgentSessionStoreRecord } from './agent-session-store-row-rules' + +export type LegacyAgentSessionRecords = + | { kind: 'absent' } + /** The primary parsed, or it was unusable and `.bak` parsed (`fromBackup`). */ + | { kind: 'loaded'; state: AgentSessionStoreState; fromBackup: boolean } + /** Present, and neither the primary nor `.bak` parses: no read will change that. */ + | { kind: 'unusable'; error: unknown } + /** Reading the primary or `.bak` failed in a way that can clear (EACCES, EIO, EMFILE). */ + | { kind: 'unavailable'; error: unknown } + +/** Why the copy found nothing to take, or took less than the file holds. */ +export type LegacyAgentSessionRecordImportReport = + | { kind: 'unusable' | 'unavailable'; error: unknown } + | { kind: 'host-mismatch'; fileHostId: string; hostId: string } + +function readFailedTransiently(error: unknown): boolean { + const cause = error instanceof Error ? error.cause : undefined + return ( + !(error instanceof Error && error.message === 'agent_session_store_corrupt') || + (typeof cause === 'object' && cause !== null && 'code' in cause && cause.code !== 'ENOENT') + ) +} + +/** Never throws. */ +export async function readLegacyAgentSessionRecords( + stateDirectory: string, + hostId: string +): Promise { + try { + const loaded = await loadAgentSessionStore(legacyAgentSessionStorePath(stateDirectory), hostId) + return loaded.storeFound + ? { kind: 'loaded', state: loaded.state, fromBackup: loaded.recoveredFromBackup } + : { kind: 'absent' } + } catch (error) { + return readFailedTransiently(error) + ? { kind: 'unavailable', error } + : { kind: 'unusable', error } + } +} + +function safeFence(value: unknown): number | null { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null +} + +/** + * A readable record whose id also has a set-aside copy: an older build recovered the readable one + * from its backup and could not read the newer copy, which may already have granted fences the + * readable one cannot show. The next grant must clear both. + */ +function withFloorAboveSetAsideCopy(record: AgentSessionRecord, raw: unknown): AgentSessionRecord { + let floor = Math.max(record.lease.minimumNextFence ?? 0, nextAgentSessionFence(record.lease) + 1) + const rawLease = typeof raw === 'object' && raw !== null && 'lease' in raw ? raw.lease : undefined + if (typeof rawLease === 'object' && rawLease !== null && 'runtimeFence' in rawLease) { + const runtimeFence = safeFence(rawLease.runtimeFence) + const minimumNextFence = + 'minimumNextFence' in rawLease && rawLease.minimumNextFence !== undefined + ? safeFence(rawLease.minimumNextFence) + : undefined + if (runtimeFence !== null && minimumNextFence !== null) { + floor = Math.max(floor, nextAgentSessionFence({ runtimeFence, minimumNextFence })) + } + } + if (!Number.isSafeInteger(floor)) { + throw new Error('agent_session_fence_exhausted') + } + return { ...record, lease: { ...record.lease, minimumNextFence: floor } } +} + +function importRows( + state: AgentSessionStoreState, + fromBackup: boolean +): AgentSessionStoreImportRows { + const records = new Map(state.records) + if (fromBackup) { + // The commit the backup lost may have granted a fence it cannot show: main's rule for this state. + raiseAgentSessionFencesAfterBackupRecovery({ ...state, records }) + } + const rows: AgentSessionStoreImportRows['records'] = [] + for (const [sessionId, loaded] of records) { + const setAside = state.unreadableRecords.get(sessionId) + const record = setAside ? withFloorAboveSetAsideCopy(loaded, setAside.raw) : loaded + const json = JSON.stringify({ ...record, lease: withoutRetiredLeaseLatches(record.lease) }) + // A record the load rules refuse is kept as its bytes, which every load then sets aside. + rows.push([ + sessionId, + isReadableAgentSessionStoreRecord(sessionId, JSON.parse(json)) ? json : JSON.stringify(loaded) + ]) + } + for (const [sessionId, { raw }] of state.unreadableRecords) { + if (!records.has(sessionId)) { + // Verbatim: a build that can read it gets it back, and this one derives it as unreadable. + rows.push([sessionId, JSON.stringify(raw ?? null)]) + } + } + return { + records: rows, + operations: [...state.operations].map(([key, row]) => [key, JSON.stringify(row)]), + retiredClaimKeys: state.retiredClaimKeys, + sessionTabs: + state.sessionTabs?.entries().map(([tabId, sessionId]) => ({ tabId, sessionId })) ?? null + } +} + +/** What the migration runs for `legacy`, each failure reported once here. */ +export function legacyAgentSessionRecordImport( + legacy: LegacyAgentSessionRecords, + hostId: string, + report: (report: LegacyAgentSessionRecordImportReport) => void +): JournalLegacyRecordImport { + switch (legacy.kind) { + case 'absent': + return NO_LEGACY_JOURNAL_RECORDS + case 'unavailable': + report(legacy) + return { owed: true } + case 'unusable': + report(legacy) + return NO_LEGACY_JOURNAL_RECORDS + case 'loaded': + break + } + let rows: AgentSessionStoreImportRows + try { + rows = importRows(legacy.state, legacy.fromBackup) + } catch (error) { + report({ kind: 'unusable', error }) + return NO_LEGACY_JOURNAL_RECORDS + } + if (legacy.state.hostId !== hostId) { + // Owners it names answer `unresolved` against this host, the conservative verdict. + report({ kind: 'host-mismatch', fileHostId: legacy.state.hostId, hostId }) + } + return { owed: false, write: (db) => insertAgentSessionStoreRowsIfAbsent(db, rows) } +} diff --git a/src/main/runtime/agent-session-record-founding.ts b/src/main/runtime/agent-session-record-founding.ts new file mode 100644 index 000000000000..d759a2461e0c --- /dev/null +++ b/src/main/runtime/agent-session-record-founding.ts @@ -0,0 +1,59 @@ +import { + AGENT_SESSION_RECORD_SCHEMA_VERSION, + type AgentSessionRecord +} from '../../shared/agent-session-record' + +/** Who a conversation's agent is and how it launches: the same whether it is created or founded. */ +export type AgentSessionRecordIdentity = Pick< + AgentSessionRecord, + 'sessionId' | 'location' | 'provider' | 'accountHome' | 'options' | 'launchArgs' +> + +export function agentSessionRecordIdentityFields( + identity: AgentSessionRecordIdentity, + now: number +) { + return { + schemaVersion: AGENT_SESSION_RECORD_SCHEMA_VERSION, + sessionId: identity.sessionId, + location: identity.location, + provider: identity.provider, + accountHome: identity.accountHome, + ...(identity.options ? { options: { ...identity.options } } : {}), + ...(identity.launchArgs ? { launchArgs: [...identity.launchArgs] } : {}), + createdAt: now, + updatedAt: now + } +} + +/** + * A conversation no agent has run yet, at rest: its first send starts one. The empty handle chain + * is what makes that start a fresh conversation rather than a resume. + */ +export function foundAgentSessionRecord( + identity: AgentSessionRecordIdentity, + lease: { claimKeyId: string; now: number } +): AgentSessionRecord { + return { + ...agentSessionRecordIdentityFields(identity, lease.now), + providerHandleChain: [], + lease: { + sessionId: identity.sessionId, + runtimeKind: 'native', + // Not 0: clients echo the fence as their expected fence, which the wire requires positive. + runtimeFence: 1, + handoffStage: null, + provenHandleLinkId: null, + ownerProcess: null, + reservedSpawnToken: null, + leaseDeadlineAt: lease.now, + lastRenewedAt: lease.now, + handoffOperationId: null, + journalCheckpoint: null, + claimKeyId: lease.claimKeyId, + claimStatus: 'released', + unreconciled: false, + deathEvidence: null + } + } +} diff --git a/src/main/runtime/agent-session-record-rows.ts b/src/main/runtime/agent-session-record-rows.ts new file mode 100644 index 000000000000..be6f70f06e7d --- /dev/null +++ b/src/main/runtime/agent-session-record-rows.ts @@ -0,0 +1,276 @@ +// The agent-session store's rows in the host's chat journal database: every row loaded once at open, +// exactly the rows a transaction changed written back, and the one-time copy of the records file. +// +// A record row this build cannot read is derived as unreadable at each load and never rewritten: a +// write only touches changed rows, and every mutation of an unreadable id is refused. + +import { + AGENT_SESSION_RECORD_SCHEMA_VERSION, + isPersistedAgentSessionRecord, + type AgentSessionLease +} from '../../shared/agent-session-record' +import { normalizeLegacyHandoffRecord } from '../../shared/agent-session-legacy-handoff-lease' +import type Database from '../sqlite/sync-database' +import type { SqliteRow } from '../sqlite/sqlite-statement' +import { + AGENT_SESSION_STORE_SCHEMA_VERSION, + type AgentSessionStoreState +} from './agent-session-record-store-file' +import type { AgentSessionStoreRowWrites } from './agent-session-store-draft' +import { + isReadableAgentSessionStoreOperation, + isReadableAgentSessionStoreRecord, + isReadableAgentSessionStoreTab, + isReadableRetiredAgentSessionClaimKey +} from './agent-session-store-row-rules' +import { AgentSessionTabTable, type PersistedAgentSessionTab } from './agent-session-tab-table' + +/** "Never recorded" and "recorded, now empty" differ, and #17439's legacy fallback needs the first; + * no row count can say which. */ +const SESSION_TABS_RECORDED = 'session_tabs_recorded' + +/** Latch fields older builds wrote. Nothing reads them, and dropping them keeps a lease this build + * writes back from carrying a stale latch to an older build after a downgrade. */ +type RetiredAgentSessionLeaseFields = { + processlessAt?: unknown + settlementRetryRequired?: unknown + settlementRetryId?: unknown +} + +export function withoutRetiredLeaseLatches(lease: AgentSessionLease): AgentSessionLease { + const { + processlessAt: _processlessAt, + settlementRetryRequired: _settlementRetryRequired, + settlementRetryId: _settlementRetryId, + ...current + }: AgentSessionLease & RetiredAgentSessionLeaseFields = lease + return current +} + +function text(row: SqliteRow, column: string): string | null { + const value = row[column] + return typeof value === 'string' ? value : null +} + +function parseJson(json: string | null): { ok: true; value: unknown } | { ok: false } { + if (json === null) { + return { ok: false } + } + try { + return { ok: true, value: JSON.parse(json) } + } catch { + return { ok: false } + } +} + +function unreadableRecordReason(value: unknown): string { + if (isPersistedAgentSessionRecord(value)) { + return 'record_key_session_id_mismatch' + } + const schemaVersion = + typeof value === 'object' && value !== null && 'schemaVersion' in value + ? value.schemaVersion + : undefined + return schemaVersion === AGENT_SESSION_RECORD_SCHEMA_VERSION + ? 'current_shape_invalid' + : 'unsupported_schema' +} + +/** + * The whole store, read once. Every lease loads unreconciled: the process that wrote it may still + * be alive, so nothing persisted grants a writer until this host adjudicates it. Operation, key and + * tab rows this build cannot read are skipped, as a record row it cannot read is set aside. + */ +export function loadAgentSessionStoreRows( + db: Database.Database, + hostId: string +): AgentSessionStoreState { + const state: AgentSessionStoreState = { + schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, + hostId, + records: new Map(), + operations: new Map(), + retiredClaimKeys: [], + unreadableRecords: new Map(), + sessionTabs: null + } + // Only a read-only database a newer build wrote can lack them; its chats then list as none here. + if ( + !db + .prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?") + .get('agent_session_records') + ) { + return state + } + for (const row of db + .prepare('SELECT session_id, record_json FROM agent_session_records ORDER BY rowid') + .all()) { + const sessionId = text(row, 'session_id') + if (sessionId === null) { + continue + } + const parsed = parseJson(text(row, 'record_json')) + const value = parsed.ok ? parsed.value : text(row, 'record_json') + if (parsed.ok && isReadableAgentSessionStoreRecord(sessionId, value)) { + const { record } = normalizeLegacyHandoffRecord(value) + state.records.set(sessionId, { + ...record, + lease: { ...withoutRetiredLeaseLatches(record.lease), unreconciled: true } + }) + } else { + state.unreadableRecords.set(sessionId, { reason: unreadableRecordReason(value), raw: value }) + } + } + for (const row of db + .prepare('SELECT operation_key, row_json FROM agent_session_operations ORDER BY rowid') + .all()) { + const key = text(row, 'operation_key') + const parsed = parseJson(text(row, 'row_json')) + if (key !== null && parsed.ok && isReadableAgentSessionStoreOperation(key, parsed.value)) { + state.operations.set(key, parsed.value) + } + } + for (const row of db + .prepare( + 'SELECT key_id AS keyId, retired_at AS retiredAt FROM agent_session_retired_claim_keys ORDER BY rowid' + ) + .all()) { + const entry = { keyId: row.keyId, retiredAt: Number(row.retiredAt) } + if (isReadableRetiredAgentSessionClaimKey(entry)) { + state.retiredClaimKeys.push(entry) + } + } + const recorded = db + .prepare('SELECT 1 AS present FROM agent_session_store_meta WHERE key = ?') + .get(SESSION_TABS_RECORDED) + const table = new AgentSessionTabTable() + for (const row of db + .prepare( + 'SELECT tab_id AS tabId, session_id AS sessionId FROM agent_session_tabs ORDER BY position' + ) + .all()) { + const entry = { tabId: row.tabId, sessionId: row.sessionId } + if ( + isReadableAgentSessionStoreTab(entry) && + table.sessionIdFor(entry.tabId) === undefined && + table.tabIdFor(entry.sessionId) === undefined + ) { + table.show(entry.sessionId, entry.tabId) + } + } + if (recorded) { + state.sessionTabs = table + } else if (table.sessionIds().length > 0) { + state.unrecordedSessionTabs = table + } + return state +} + +function writeTabIndex( + db: Database.Database, + index: { recorded: boolean; tabs: readonly PersistedAgentSessionTab[] } +): void { + db.prepare('DELETE FROM agent_session_tabs').run() + const insert = db.prepare( + 'INSERT INTO agent_session_tabs (tab_id, session_id, position) VALUES (?, ?, ?)' + ) + index.tabs.forEach(({ tabId, sessionId }, position) => insert.run(tabId, sessionId, position)) + if (index.recorded) { + db.prepare('INSERT OR REPLACE INTO agent_session_store_meta (key, value) VALUES (?, ?)').run( + SESSION_TABS_RECORDED, + '1' + ) + } else { + db.prepare('DELETE FROM agent_session_store_meta WHERE key = ?').run(SESSION_TABS_RECORDED) + } +} + +/** Exactly the rows a transaction changed. The caller holds the journal transaction. */ +export function writeAgentSessionStoreRows( + db: Database.Database, + writes: AgentSessionStoreRowWrites +): void { + const upsertRecord = db.prepare( + 'INSERT INTO agent_session_records (session_id, record_json) VALUES (?, ?) ON CONFLICT(session_id) DO UPDATE SET record_json = excluded.record_json' + ) + for (const [sessionId, json] of writes.records.upsert) { + upsertRecord.run(sessionId, json) + } + for (const sessionId of writes.records.remove) { + db.prepare('DELETE FROM agent_session_records WHERE session_id = ?').run(sessionId) + } + const upsertOperation = db.prepare( + 'INSERT INTO agent_session_operations (operation_key, row_json) VALUES (?, ?) ON CONFLICT(operation_key) DO UPDATE SET row_json = excluded.row_json' + ) + for (const [key, json] of writes.operations.upsert) { + upsertOperation.run(key, json) + } + for (const key of writes.operations.remove) { + db.prepare('DELETE FROM agent_session_operations WHERE operation_key = ?').run(key) + } + if (writes.retiredClaimKeys) { + db.prepare('DELETE FROM agent_session_retired_claim_keys').run() + const insert = db.prepare( + 'INSERT INTO agent_session_retired_claim_keys (key_id, retired_at) VALUES (?, ?)' + ) + for (const { keyId, retiredAt } of writes.retiredClaimKeys) { + insert.run(keyId, retiredAt) + } + } + if (writes.sessionTabs) { + writeTabIndex(db, writes.sessionTabs) + } +} + +/** What the one-time import copies in, each row already serialized. */ +export type AgentSessionStoreImportRows = { + records: [sessionId: string, json: string][] + operations: [key: string, json: string][] + retiredClaimKeys: { keyId: string; retiredAt: number }[] + /** Null when the file never recorded a tab index. */ + sessionTabs: PersistedAgentSessionTab[] | null +} + +/** Inserts each row whose key the database does not hold yet: a chat created while the import was + * owed keeps its own rows, and ids never collide with the file's. */ +export function insertAgentSessionStoreRowsIfAbsent( + db: Database.Database, + rows: AgentSessionStoreImportRows +): void { + const insertRecord = db.prepare( + 'INSERT OR IGNORE INTO agent_session_records (session_id, record_json) VALUES (?, ?)' + ) + for (const [sessionId, json] of rows.records) { + insertRecord.run(sessionId, json) + } + const insertOperation = db.prepare( + 'INSERT OR IGNORE INTO agent_session_operations (operation_key, row_json) VALUES (?, ?)' + ) + for (const [key, json] of rows.operations) { + insertOperation.run(key, json) + } + const insertKey = db.prepare( + 'INSERT OR IGNORE INTO agent_session_retired_claim_keys (key_id, retired_at) VALUES (?, ?)' + ) + for (const { keyId, retiredAt } of rows.retiredClaimKeys) { + insertKey.run(keyId, retiredAt) + } + const tabs = rows.sessionTabs + if (tabs) { + const insertTab = db.prepare( + 'INSERT OR IGNORE INTO agent_session_tabs (tab_id, session_id, position) VALUES (?, ?, ?)' + ) + // Ahead of any tab a chat created while the import was owed recorded: these were open first. + tabs.forEach(({ tabId, sessionId }, position) => + insertTab.run(tabId, sessionId, position - tabs.length) + ) + db.prepare('INSERT OR IGNORE INTO agent_session_store_meta (key, value) VALUES (?, ?)').run( + SESSION_TABS_RECORDED, + '1' + ) + } else { + // An index a chat created while the import was owed recorded holds none of the file's chats: + // "never recorded" sends restore to the profile's tabs, with that chat's tab row beside them. + db.prepare('DELETE FROM agent_session_store_meta WHERE key = ?').run(SESSION_TABS_RECORDED) + } +} diff --git a/src/main/runtime/agent-session-record-store-file.ts b/src/main/runtime/agent-session-record-store-file.ts index 0aa32f74f755..79e65388fbd0 100644 --- a/src/main/runtime/agent-session-record-store-file.ts +++ b/src/main/runtime/agent-session-record-store-file.ts @@ -1,13 +1,9 @@ /** - * On-disk layer for the durable agent-session store. - * - * Every mutation is a whole-file atomic transaction — temp write, fsync, rename — so a SIGKILL - * at any point leaves either the previous committed state or the next one, never a torn lease. - * That matters because this host restarts its runtime often; a half-written lease would be - * indistinguishable from an owner whose identity cannot be verified. + * The records file the agent-session store kept before it moved into the chat journal database, + * read once by the version-4 migration (agent-session-legacy-record-import.ts) and never written. + * Its `.bak` fallback and salvage survive here for that one read, until the import is retired. */ -import { createHash } from 'node:crypto' import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { @@ -21,15 +17,11 @@ import { type AgentSessionRecord } from '../../shared/agent-session-record' import { normalizeLegacyHandoffRecord } from '../../shared/agent-session-legacy-handoff-lease' -import { agentSessionStoreBackupPath as backupPath } from './agent-session-record-store-write' -export { saveAgentSessionStore } from './agent-session-record-store-write' import { parseAgentSessionTabTable, type AgentSessionTabTable } from './agent-session-tab-table' -import { serializeAgentSessionStoreState } from './agent-session-store-serialization' export const AGENT_SESSION_STORE_SCHEMA_VERSION = 2 as const -/** In a host's state directory, beside the journal database: one file adjudicates every session's - * lease. */ +/** In a host's state directory, beside the journal database, where the records file lived. */ export const AGENT_SESSION_STORE_DIR_NAME = 'agent-sessions' export const AGENT_SESSION_STORE_FILE_NAME = 'agent-sessions.json' @@ -46,6 +38,8 @@ export type AgentSessionStoreState = { unreadableRecords: Map /** Chat tab id → the conversation it shows; null until this store first records a tab. */ sessionTabs: AgentSessionTabTable | null + /** Tab rows an index never recorded holds (chats opened while the import was owed). */ + unrecordedSessionTabs?: AgentSessionTabTable } export type LoadedAgentSessionStore = { @@ -55,16 +49,19 @@ export type LoadedAgentSessionStore = { readOnly: boolean /** True when the primary file was unusable and the previous committed copy was used. */ recoveredFromBackup: boolean - /** True when the normalized current-schema quarantine must be persisted. */ - needsRewrite: boolean - /** True when decode mapped a lease value only the removed terminal handoff wrote. */ - legacyHandoffLeasesNormalized: boolean } export function agentSessionStorePath(directory: string): string { return join(directory, AGENT_SESSION_STORE_FILE_NAME) } +/** The records file of the host whose state directory this is. */ +export function legacyAgentSessionStorePath(stateDirectory: string): string { + return agentSessionStorePath(join(stateDirectory, AGENT_SESSION_STORE_DIR_NAME)) +} + +const backupPath = (filePath: string): string => `${filePath}.bak` + function emptyState(hostId: string): AgentSessionStoreState { return { schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, @@ -77,21 +74,7 @@ function emptyState(hostId: string): AgentSessionStoreState { } } -export function agentSessionStoreRevision(state: AgentSessionStoreState): string { - return createHash('sha256') - .update(String(state.schemaVersion)) - .update('\0') - .update(serializeAgentSessionStoreState(state)) - .digest('hex') -} - -function parseState( - raw: string, - hostId: string -): Pick< - LoadedAgentSessionStore, - 'state' | 'needsRewrite' | 'legacyHandoffLeasesNormalized' -> | null { +function parseState(raw: string, hostId: string): Pick | null { let parsed: unknown try { parsed = JSON.parse(raw) @@ -144,8 +127,6 @@ function parseState( const state = emptyState(hostId) state.schemaVersion = schemaVersion state.hostId = file.hostId - let needsRewrite = false - let legacyHandoffLeasesNormalized = false if (typeof file.records === 'object' && file.records !== null) { for (const [sessionId, value] of Object.entries(file.records)) { const decoded = isPersistedAgentSessionRecord(value) @@ -154,8 +135,6 @@ function parseState( const record = decoded?.record ?? null if (record?.sessionId === sessionId) { state.records.set(sessionId, record) - // Why: mapped while parsing, so every revision is taken over the same normalized state. - legacyHandoffLeasesNormalized ||= decoded?.normalized === true } else { const valueSchemaVersion = typeof value === 'object' && @@ -167,7 +146,6 @@ function parseState( ? 'current_shape_invalid' : 'unsupported_schema' state.unreadableRecords.set(sessionId, { reason, raw: value }) - needsRewrite ||= schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION } } } @@ -233,7 +211,7 @@ function parseState( return null } state.sessionTabs = sessionTabs.table - return { state, needsRewrite, legacyHandoffLeasesNormalized } + return { state } } /** A record the primary retained as unreadable may still have a valid copy in the previous @@ -283,12 +261,9 @@ export async function loadAgentSessionStore( } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { // Only a missing or unparseable primary means "fall back". A transient read failure - // (EACCES, EIO, EMFILE) says nothing about the primary's contents, and treating it as - // recovery would replace newer state with a stale backup and latch the recovery path. - if (!recoveredFromBackup) { - throw new Error('agent_session_store_corrupt') - } - unusableStoreFound = true + // (EACCES, EIO, EMFILE) says nothing about either copy's contents: the primary is not + // replaced by a stale backup, and a backup that could not be read is not unusable. + throw new Error('agent_session_store_corrupt', { cause: error }) } continue } @@ -314,8 +289,6 @@ export async function loadAgentSessionStore( state: emptyState(hostId), storeFound: false, readOnly: false, - recoveredFromBackup: false, - needsRewrite: false, - legacyHandoffLeasesNormalized: false + recoveredFromBackup: false } } diff --git a/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts b/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts deleted file mode 100644 index 4180af2580b3..000000000000 --- a/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Decoding a lease the removed terminal handoff wrote. Separate from the store's main suite only - * because that file is at its line cap. - */ - -import { mkdtemp, readFile, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' -import { - readPersistedLease, - writeOlderBuildLease -} from './agent-session-older-build-lease.test-fixture' -import type { AgentSessionRecordStore } from './agent-session-record-store' -import { - openTestAgentSessionRecordStore, - testAgentSessionStoreFilePath -} from './agent-session-record-store-test-harness' - -const NOW = 1_800_000_000_000 -const SESSION = 'session-alpha' -const MATCHED: AgentSessionOwnerProbe = { outcome: 'identity-matched', matchedOn: ['spawn-token'] } - -let directory: string - -beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-legacy-lease-')) -}) - -afterEach(async () => { - await rm(directory, { recursive: true, force: true }) -}) - -const open = (): Promise => openTestAgentSessionRecordStore(directory) - -async function persistLiveOwner(): Promise { - const store = await open() - const reserved = await store.reserveOwner({ - sessionId: SESSION, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' - }, - provider: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, - // Without one the next open backfills it, and that rewrite would mask the lease's own. - surfaceTabId: 'tab-alpha', - expectedFence: null, - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'indeterminate', reason: 'no answer' }, - operation: { - callerKey: 'client-1', - operationId: `${NOW}-${'0'.repeat(32)}`, - fingerprint: 'fp' - }, - now: NOW - }) - const fence = reserved.record.lease.runtimeFence - await store.commitProcessIdentity({ - sessionId: SESSION, - fence, - process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW - 1_000, spawnToken: 'spawn-a' }, - now: NOW - }) - await store.proveOwner({ - sessionId: SESSION, - fence, - link: { - linkId: 'link-1', - origin: 'created', - mintedAtFence: fence, - observedAt: NOW, - handle: { provider: 'claude', sessionId: 'provider-session-alpha', leafUuid: null } - }, - now: NOW - }) - // Commits the visibility index, so a later hide changes nothing but a pending rewrite. - await store.setSessionTabVisibility(SESSION, false) -} - -describe('a lease the removed terminal handoff wrote', () => { - it('loads normalized instead of quarantined, and reaches disk with the first transaction', async () => { - await persistLiveOwner() - await writeOlderBuildLease(directory, SESSION, { - runtimeKind: 'tui', - handoffStage: 'old-owner-stopped', - handoffOperationId: 'op-handoff' - }) - const legacy = await readFile(testAgentSessionStoreFilePath(directory), 'utf-8') - - const store = await open() - expect(store.isSessionUnreadable(SESSION)).toBe(false) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - runtimeKind: 'native', - handoffStage: 'recovering', - handoffOperationId: 'op-handoff', - claimStatus: 'conflicted' - }) - // An open does not write: another holder of the file mid-restart would read it as a change. - expect(await readFile(testAgentSessionStoreFilePath(directory), 'utf-8')).toBe(legacy) - - // A transaction that changes nothing still writes it. Had the open's revision been taken over - // anything but the normalized state, this would read the file as externally changed and - // reload it, dropping the pending write. - await store.setSessionTabVisibility(SESSION, false) - expect(await readPersistedLease(directory, SESSION)).toMatchObject({ - runtimeKind: 'native', - handoffStage: 'recovering', - claimStatus: 'conflicted' - }) - }) - - it('keeps the adjudicated lease after a save, so the saved file hashes to memory', async () => { - await persistLiveOwner() - await writeOlderBuildLease(directory, SESSION, { runtimeKind: 'tui' }) - const store = await open() - await store.reconcileOnRestart({ probe: async () => MATCHED, now: NOW + 1_000 }) - expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(false) - - // A reload after a mismatched hash would mark every lease unadjudicated again. - await store.setSessionTabVisibility(SESSION, true) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - unreconciled: false, - claimStatus: 'conflicted', - handoffStage: 'recovering' - }) - const settled = await readFile(testAgentSessionStoreFilePath(directory), 'utf-8') - await open() - expect(await readFile(testAgentSessionStoreFilePath(directory), 'utf-8')).toBe(settled) - }) -}) diff --git a/src/main/runtime/agent-session-record-store-security.test.ts b/src/main/runtime/agent-session-record-store-security.test.ts deleted file mode 100644 index 63ee368f8737..000000000000 --- a/src/main/runtime/agent-session-record-store-security.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { chmod, mkdtemp, rm, stat } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { AgentSessionRecordStore } from './agent-session-record-store' -import { agentSessionStorePath } from './agent-session-record-store-file' -import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' - -const NOW = 1_800_000_000_000 -let directory: string - -function reserveRequest(): AgentSessionReserveRequest { - return { - sessionId: 'session-created', - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-created', - workspaceKind: 'folder' - }, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/accounts/created' }, - expectedFence: null, - spawnToken: 'spawn-created', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' }, - operation: { - callerKey: 'client-1', - operationId: `${NOW}-00000000000000000000000000000001`, - fingerprint: 'fp-1' - }, - now: NOW - } -} - -beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-security-')) -}) - -afterEach(async () => { - await rm(directory, { recursive: true, force: true }) -}) - -describe('agent session record store security', () => { - it.skipIf(process.platform === 'win32')( - 'creates the directory and store owner-only', - async () => { - const nestedDirectory = join(directory, 'agent-sessions') - await chmod(directory, 0o755) - const store = await AgentSessionRecordStore.open({ - directory: nestedDirectory, - hostId: 'local' - }) - await store.reserveOwner(reserveRequest()) - - expect((await stat(nestedDirectory)).mode & 0o777).toBe(0o700) - expect((await stat(agentSessionStorePath(nestedDirectory))).mode & 0o777).toBe(0o600) - } - ) -}) diff --git a/src/main/runtime/agent-session-record-store-security.ts b/src/main/runtime/agent-session-record-store-security.ts deleted file mode 100644 index b7293f56c99e..000000000000 --- a/src/main/runtime/agent-session-record-store-security.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { chmod, mkdir } from 'node:fs/promises' -import { dirname } from 'node:path' -import { - loadAgentSessionStore, - type LoadedAgentSessionStore -} from './agent-session-record-store-file' -import { withFileTransactionLock } from '../file-transaction-lock' - -const OWNER_DIRECTORY_MODE = 0o700 -const OWNER_FILE_MODE = 0o600 - -async function chmodIfPresent(path: string, mode: number): Promise { - try { - await chmod(path, mode) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error - } - } -} - -export async function hardenAgentSessionStorePermissions(filePath: string): Promise { - const directory = dirname(filePath) - await mkdir(directory, { recursive: true, mode: OWNER_DIRECTORY_MODE }) - await chmod(directory, OWNER_DIRECTORY_MODE) - await Promise.all([ - chmodIfPresent(filePath, OWNER_FILE_MODE), - chmodIfPresent(`${filePath}.bak`, OWNER_FILE_MODE) - ]) -} - -export async function loadProtectedAgentSessionStore( - filePath: string, - hostId: string -): Promise { - return withFileTransactionLock(filePath, async () => { - await hardenAgentSessionStorePermissions(filePath) - return loadAgentSessionStore(filePath, hostId) - }) -} diff --git a/src/main/runtime/agent-session-record-store-tab-table.test.ts b/src/main/runtime/agent-session-record-store-tab-table.test.ts index c5d4be5319cf..40ee8a36b86c 100644 --- a/src/main/runtime/agent-session-record-store-tab-table.test.ts +++ b/src/main/runtime/agent-session-record-store-tab-table.test.ts @@ -3,7 +3,7 @@ * Separate from the store's main suite only because that file is at its line cap. */ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -13,8 +13,7 @@ import { isAgentSessionRefusalError } from '../../shared/agent-session-wire-refu import type { AgentSessionRecordStore } from './agent-session-record-store' import { openTestAgentSessionRecordStore, - readPersistedTestAgentSessionStore, - testAgentSessionStoreFilePath + readPersistedTestAgentSessionStore } from './agent-session-record-store-test-harness' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' @@ -70,8 +69,6 @@ async function open(): Promise { describe('chat tab table', () => { const LEGACY_TAB_ID = 'structured-agent-session-session-alpha' - const filePath = () => testAgentSessionStoreFilePath(directory) - const readFileJson = async () => JSON.parse(await readFile(filePath(), 'utf-8')) it('takes a reserved id only when the tab is shown, and refuses it to a second chat', async () => { const store = await open() @@ -159,123 +156,4 @@ describe('chat tab table', () => { await store.setSessionTabVisibility('session-alpha', true, 'tab-restored') expect(store.getSessionTabId('session-alpha')).toBe('tab-restored') }) - - it('seeds the table from an older store, keeping each visible chat on the id it has today', async () => { - const first = await open() - await first.reserveOwner(reserveRequest()) - await first.reserveOwner( - reserveRequest({ - sessionId: 'session-beta', - operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } - }) - ) - await first.reserveOwner( - reserveRequest({ - sessionId: 'session-gamma', - operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-3' } - }) - ) - // What a build before the table wrote: the visible list, and a tab id on some records. - const raw = await readFileJson() - delete raw.sessionTabs - raw.visibleSessionIds = ['session-alpha', 'session-beta'] - raw.records['session-alpha'].surfaceTabId = 'tab-alpha' - raw.records['session-beta'].surfaceTabId = 'structured-agent-session-session-beta' - const legacy = JSON.stringify(raw) - await writeFile(filePath(), legacy) - - const reopened = await open() - expect(reopened.getSessionTabId('session-alpha')).toBe('tab-alpha') - expect(reopened.getSessionTabId('session-beta')).toBe('structured-agent-session-session-beta') - expect(reopened.getSessionTabId('session-gamma')).toBeNull() - // Seeding is part of reading, so an open alone writes nothing. - expect(await readFile(filePath(), 'utf-8')).toBe(legacy) - - await reopened.setSessionTabVisibility('session-gamma', true) - const persisted = await readFileJson() - expect(persisted.sessionTabs).toEqual([ - { tabId: 'tab-alpha', sessionId: 'session-alpha' }, - { tabId: 'structured-agent-session-session-beta', sessionId: 'session-beta' }, - { tabId: 'structured-agent-session-session-gamma', sessionId: 'session-gamma' } - ]) - // Older builds restore from this list; the record field rides along untouched and unread. - expect(persisted.visibleSessionIds).toEqual(['session-alpha', 'session-beta', 'session-gamma']) - expect(persisted.records['session-alpha'].surfaceTabId).toBe('tab-alpha') - }) - - it('seeds a chat cleared before the upgrade under the id its tab opened with', async () => { - const first = await open() - for (const [index, sessionId] of ['session-alpha', 'clear-one', 'clear-two'].entries()) { - await first.reserveOwner( - reserveRequest({ - sessionId, - operation: { - callerKey: 'client-1', - operationId: operationId(), - fingerprint: `fp-chain-${index}` - } - }) - ) - } - // An older build after two clears, with the first conversation reopened from history. - const raw = await readFileJson() - const cleared = (replacementSessionId: string) => ({ - command: 'clear', - state: 'completed', - phase: 'committed', - operationId: operationId(), - callerKey: 'client-1', - replacementSessionId - }) - raw.records['session-alpha'].conversationCommand = cleared('clear-one') - raw.records['clear-one'].conversationCommand = cleared('clear-two') - raw.records['clear-two'].surfaceTabId = 'structured-agent-session-clear-two' - delete raw.sessionTabs - raw.visibleSessionIds = ['session-alpha', 'clear-two'] - await writeFile(filePath(), JSON.stringify(raw)) - - const reopened = await open() - expect(reopened.getSessionTabId('clear-two')).toBe(LEGACY_TAB_ID) - const reopenedTab = reopened.getSessionTabId('session-alpha') - expect(reopenedTab).not.toBe(LEGACY_TAB_ID) - expect(reopenedTab).not.toContain(':') - expect(reopened.listVisibleSessionIds()).toEqual(['session-alpha', 'clear-two']) - // Seeding is part of reading, so it must give the same ids on every read of the same bytes. - expect((await open()).getSessionTabId('session-alpha')).toBe(reopenedTab) - }) - - it('reads the table, never the record field, once the table is on disk', async () => { - const first = await open() - await first.reserveOwner(reserveRequest()) - await first.setSessionTabVisibility('session-alpha', true, 'tab-alpha') - const raw = await readFileJson() - raw.records['session-alpha'].surfaceTabId = 'tab-stale' - await writeFile(filePath(), JSON.stringify(raw)) - expect((await open()).getSessionTabId('session-alpha')).toBe('tab-alpha') - }) - - it('keeps a record whose legacy tab id is malformed, seeding it under the derived id', async () => { - const first = await open() - await first.reserveOwner(reserveRequest()) - const raw = await readFileJson() - delete raw.sessionTabs - raw.visibleSessionIds = ['session-alpha'] - raw.records['session-alpha'].surfaceTabId = 'agent-session:session-alpha' - await writeFile(filePath(), JSON.stringify(raw)) - const reopened = await open() - expect(reopened.isSessionUnreadable('session-alpha')).toBe(false) - expect(reopened.getSessionTabId('session-alpha')).toBe(LEGACY_TAB_ID) - }) - - it('treats a malformed table as a corrupt store rather than guessing', async () => { - const first = await open() - await first.reserveOwner(reserveRequest()) - const raw = await readFileJson() - raw.sessionTabs = [ - { tabId: 'tab-alpha', sessionId: 'session-alpha' }, - { tabId: 'tab-alpha', sessionId: 'session-beta' } - ] - await writeFile(filePath(), JSON.stringify(raw)) - await expect(open()).rejects.toThrow('agent_session_store_corrupt') - }) }) diff --git a/src/main/runtime/agent-session-record-store-test-harness.ts b/src/main/runtime/agent-session-record-store-test-harness.ts index 74af065cd01b..eae3ae3baf4c 100644 --- a/src/main/runtime/agent-session-record-store-test-harness.ts +++ b/src/main/runtime/agent-session-record-store-test-harness.ts @@ -1,94 +1,189 @@ /** * The one way tests open, seed and read back the durable agent-session record store, so a change * to where or how the store persists is made here rather than in every test that uses it. Every - * function takes the host's state directory, the one its journal database lives in, and keeps the - * store where the runtime does within it. + * function takes the host's state directory: the store is rows in its chat journal database, opened + * through the test database registry, so `closeTestJournalHostDatabases` closes it too. */ -import { mkdir, readFile, writeFile } from 'node:fs/promises' -import { join } from 'node:path' import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' import type { PersistedAgentSessionRecord } from '../../shared/agent-session-legacy-handoff-lease' +import { JOURNAL_DB_SCHEMA_VERSION } from '../native-chat/agent-session-journal/journal-database-schema' +import { + closeTestJournalHostDatabase, + openTestJournalHostDatabase +} from '../native-chat/agent-session-journal/journal-host-database-test-support' +import type Database from '../sqlite/sync-database' import { AgentSessionRecordStore } from './agent-session-record-store' import { - AGENT_SESSION_STORE_DIR_NAME, AGENT_SESSION_STORE_SCHEMA_VERSION, - agentSessionStorePath, type RetiredAgentSessionClaimKey } from './agent-session-record-store-file' const TEST_HOST_ID = 'local' -function storeDirectory(stateDirectory: string): string { - return join(stateDirectory, AGENT_SESSION_STORE_DIR_NAME) -} - -/** The store's file, for the tests that assert on or damage its bytes directly. */ -export function testAgentSessionStoreFilePath(stateDirectory: string): string { - return agentSessionStorePath(storeDirectory(stateDirectory)) -} - /** One committed state of the store, as tests seed it and read it back. */ export type PersistedTestAgentSessionStore = { schemaVersion: number hostId: string + /** Every record row as stored, including one this build cannot read. */ records: Record operations: Record retiredClaimKeys: RetiredAgentSessionClaimKey[] + /** Written as raw record rows; always empty on a read, where `records` holds every row. */ unusableRecords: Record /** Absent until the store first records a chat tab. */ sessionTabs?: { tabId: string; sessionId: string }[] } +function databaseFor(stateDirectory: string): Database.Database { + return openTestJournalHostDatabase(stateDirectory).db +} + /** Opens, or reopens, the store in `stateDirectory`: what a fresh app process does at launch. */ -export function openTestAgentSessionRecordStore( +export async function openTestAgentSessionRecordStore( stateDirectory: string, options: { hostId?: string } = {} ): Promise { return AgentSessionRecordStore.open({ - directory: storeDirectory(stateDirectory), + journalDatabase: openTestJournalHostDatabase(stateDirectory), hostId: options.hostId ?? TEST_HOST_ID }) } +function writePersisted(db: Database.Database, persisted: PersistedTestAgentSessionStore): void { + db.exec('BEGIN IMMEDIATE') + try { + for (const table of [ + 'agent_session_records', + 'agent_session_operations', + 'agent_session_retired_claim_keys', + 'agent_session_tabs', + 'agent_session_store_meta' + ]) { + db.prepare(`DELETE FROM ${table}`).run() + } + const insertRecord = db.prepare( + 'INSERT OR REPLACE INTO agent_session_records (session_id, record_json) VALUES (?, ?)' + ) + for (const [sessionId, { raw }] of Object.entries(persisted.unusableRecords)) { + insertRecord.run(sessionId, JSON.stringify(raw ?? null)) + } + for (const [sessionId, record] of Object.entries(persisted.records)) { + insertRecord.run(sessionId, JSON.stringify(record)) + } + for (const [key, row] of Object.entries(persisted.operations)) { + db.prepare( + 'INSERT INTO agent_session_operations (operation_key, row_json) VALUES (?, ?)' + ).run(key, JSON.stringify(row)) + } + for (const { keyId, retiredAt } of persisted.retiredClaimKeys) { + db.prepare( + 'INSERT INTO agent_session_retired_claim_keys (key_id, retired_at) VALUES (?, ?)' + ).run(keyId, retiredAt) + } + if (persisted.sessionTabs) { + persisted.sessionTabs.forEach(({ tabId, sessionId }, position) => + db + .prepare('INSERT INTO agent_session_tabs (tab_id, session_id, position) VALUES (?, ?, ?)') + .run(tabId, sessionId, position) + ) + db.prepare( + "INSERT INTO agent_session_store_meta (key, value) VALUES ('session_tabs_recorded', '1')" + ).run() + } + db.exec('COMMIT') + } catch (error) { + db.exec('ROLLBACK') + throw error + } +} + /** Leaves `records` behind as an earlier run of the app would have, before anything opens it. */ export async function seedTestAgentSessionRecordStore( stateDirectory: string, seed: { records: readonly PersistedAgentSessionRecord[] } ): Promise { - const persisted: PersistedTestAgentSessionStore = { + writePersisted(databaseFor(stateDirectory), { schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, hostId: TEST_HOST_ID, records: Object.fromEntries(seed.records.map((record) => [record.sessionId, record])), operations: {}, retiredClaimKeys: [], unusableRecords: {} - } - await mkdir(storeDirectory(stateDirectory), { recursive: true }) - await writeFile(testAgentSessionStoreFilePath(stateDirectory), JSON.stringify(persisted), 'utf-8') + }) } /** Leaves an empty store a newer build wrote: this build reads it but never writes it. */ export async function seedTestAgentSessionStoreFromNewerBuild( stateDirectory: string ): Promise { - await mkdir(storeDirectory(stateDirectory), { recursive: true }) - await writeFile( - testAgentSessionStoreFilePath(stateDirectory), - JSON.stringify({ schemaVersion: 99, hostId: TEST_HOST_ID, records: {}, operations: {} }) + openTestJournalHostDatabase(stateDirectory).db.pragma( + `user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}` ) + closeTestJournalHostDatabase(stateDirectory) } -/** Everything the store has committed, as text: to assert a value never reached disk, or that an - * action wrote nothing by comparing two reads. */ -export function readPersistedTestAgentSessionStoreText(stateDirectory: string): Promise { - return readFile(testAgentSessionStoreFilePath(stateDirectory), 'utf-8') +function parsed(json: unknown): unknown { + return typeof json === 'string' ? JSON.parse(json) : null } export async function readPersistedTestAgentSessionStore( stateDirectory: string ): Promise { - return JSON.parse(await readPersistedTestAgentSessionStoreText(stateDirectory)) + const db = databaseFor(stateDirectory) + const persisted: PersistedTestAgentSessionStore = { + schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, + hostId: TEST_HOST_ID, + records: {}, + operations: {}, + retiredClaimKeys: [], + unusableRecords: {} + } + for (const row of db + .prepare('SELECT session_id, record_json FROM agent_session_records ORDER BY rowid') + .all()) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test read-back of rows the store wrote; a test asserting on a malformed row reads it as the value it stored. + persisted.records[String(row.session_id)] = parsed( + row.record_json + ) as PersistedAgentSessionRecord + } + for (const row of db + .prepare('SELECT operation_key, row_json FROM agent_session_operations ORDER BY rowid') + .all()) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test read-back of rows the store wrote after validating them. + persisted.operations[String(row.operation_key)] = parsed( + row.row_json + ) as AgentSessionOperationRow + } + for (const row of db + .prepare('SELECT key_id, retired_at FROM agent_session_retired_claim_keys ORDER BY rowid') + .all()) { + persisted.retiredClaimKeys.push({ + keyId: String(row.key_id), + retiredAt: Number(row.retired_at) + }) + } + if ( + db + .prepare( + "SELECT 1 AS present FROM agent_session_store_meta WHERE key = 'session_tabs_recorded'" + ) + .get() + ) { + persisted.sessionTabs = db + .prepare('SELECT tab_id, session_id FROM agent_session_tabs ORDER BY position') + .all() + .map((row) => ({ tabId: String(row.tab_id), sessionId: String(row.session_id) })) + } + return persisted +} + +/** Everything the store has committed, as text: to assert a value never reached disk, or that an + * action wrote nothing by comparing two reads. */ +export async function readPersistedTestAgentSessionStoreText( + stateDirectory: string +): Promise { + return JSON.stringify(await readPersistedTestAgentSessionStore(stateDirectory)) } /** Changes the committed state behind the store's back, as an older build or a damaged disk would. */ @@ -98,5 +193,5 @@ export async function editPersistedTestAgentSessionStore( ): Promise { const persisted = await readPersistedTestAgentSessionStore(stateDirectory) edit(persisted) - await writeFile(testAgentSessionStoreFilePath(stateDirectory), JSON.stringify(persisted)) + writePersisted(databaseFor(stateDirectory), persisted) } diff --git a/src/main/runtime/agent-session-record-store-write.ts b/src/main/runtime/agent-session-record-store-write.ts deleted file mode 100644 index 76f68f03de33..000000000000 --- a/src/main/runtime/agent-session-record-store-write.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Committing the durable agent-session store. - * - * Split from the read/parse half so neither outgrows its budget. The ordering below is the whole - * reason this is its own file: the live path is never absent. New content is made durable in a temp - * file first, a validated primary is COPIED to the backup, and only then does the rename publish it. - * Backup recovery keeps the known-good backup in place while publishing the repaired primary. - * - * The old ordering renamed the live file aside before writing the new one, so a death in that - * window left the profile with a backup and no primary — which is exactly the state that wedged a - * real profile. Copy, don't move. - */ - -import { chmod, mkdir, rm } from 'node:fs/promises' -import { dirname } from 'node:path' -import { - copyFileDurable, - durableWriteTempPath, - renameDurable, - writeTempFileDurable -} from '../durable-file-write' -import type { AgentSessionStoreState } from './agent-session-record-store-file' -import { serializeAgentSessionStoreState } from './agent-session-store-serialization' - -export const agentSessionStoreBackupPath = (filePath: string): string => `${filePath}.bak` - -export async function saveAgentSessionStore( - filePath: string, - state: AgentSessionStoreState, - options: { primaryStatus: 'validated' | 'unusable-or-absent' } -): Promise { - const directory = dirname(filePath) - await mkdir(directory, { recursive: true, mode: 0o700 }) - await chmod(directory, 0o700) - const tmpPath = durableWriteTempPath(filePath) - try { - await writeTempFileDurable(tmpPath, serializeAgentSessionStoreState(state), 0o600) - // Only a primary parsed under the transaction lock may replace the backup. During recovery the - // primary is corrupt or absent, so the known-good backup must survive until publication. - if (options.primaryStatus === 'validated') { - await copyFileDurable(filePath, agentSessionStoreBackupPath(filePath)) - } - await renameDurable(tmpPath, filePath) - } catch (error) { - await rm(tmpPath, { force: true }).catch(() => {}) - throw error - } -} diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts index 908f843da981..12aa70b330cd 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -1,6 +1,6 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import type { @@ -16,10 +16,10 @@ import { editPersistedTestAgentSessionStore, openTestAgentSessionRecordStore, readPersistedTestAgentSessionStore, - seedTestAgentSessionStoreFromNewerBuild, - testAgentSessionStoreFilePath + seedTestAgentSessionStoreFromNewerBuild } from './agent-session-record-store-test-harness' import { AGENT_SESSION_CLAIM_KEY_RETENTION_MS } from './agent-session-claim-key-retention' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' const NOW = 1_800_000_000_000 @@ -41,9 +41,6 @@ const FOLDER: AgentSessionExecutionLocation = { const MATCHED: AgentSessionOwnerProbe = { outcome: 'identity-matched', matchedOn: ['spawn-token'] } const INDETERMINATE: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'no answer' } const UNUSED: AgentSessionOwnerProbe = { outcome: 'reservation-unused' } -const BAD_OP_STORE = '{"schemaVersion":0,"hostId":"","records":{},"operations":{"x":0}}' -const BAD_KEY_STORE = - '{"schemaVersion":1,"hostId":"","records":{},"operations":{},"retiredClaimKeys":[0]}' let counter = 0 @@ -288,22 +285,6 @@ describe('acquisition path', () => { }) describe('concurrent claims', () => { - it('lets only one store instance reserve a session from the same disk snapshot', async () => { - const [first, second] = await Promise.all([open(), open()]) - const results = await Promise.allSettled([ - first.reserveOwner(reserveRequest()), - second.reserveOwner(reserveRequest()) - ]) - - expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1) - const refused = results.find((result) => result.status === 'rejected') - expect((refused as PromiseRejectedResult).reason.message).toBe('agent_session_conflict') - - const persisted = await open() - expect(persisted.getRecord('session-alpha')?.lease.runtimeFence).toBe(1) - expect(persisted.listOperationRows()).toHaveLength(1) - }) - it('lets exactly one of two concurrent reservations win and never spawns the loser', async () => { const store = await open() await establishOwner(store) @@ -378,18 +359,16 @@ describe('concurrent claims', () => { const store = await open() await establishOwner(store) const before = store.getRecord('session-alpha') - // Losing both committed copies must not reset the live store to empty authority. - await rm(directory, { recursive: true, force: true }) - await expect( - store.setJournalCheckpoint({ - sessionId: 'session-alpha', - fence: 1, - checkpoint: { epoch: 9, sequence: 9 }, - now: NOW - }) - ).rejects.toThrow() - expect(store.getRecord('session-alpha')).toEqual(before) - expect(store.getRecord('session-alpha')?.lease.journalCheckpoint).toBeNull() + const persistedBefore = await readPersistedTestAgentSessionStore(directory) + // The row write fails inside the commit, as a full disk or an I/O error would. + openTestJournalHostDatabase(directory).db.exec( + "CREATE TRIGGER fail_record_write BEFORE UPDATE ON agent_session_records BEGIN SELECT RAISE(ABORT, 'disk I/O error'); END" + ) + await expect(store.setConversationName('session-alpha', 'renamed')).rejects.toThrow( + 'disk I/O error' + ) + expect(store.getRecord('session-alpha')).toBe(before) + expect(await readPersistedTestAgentSessionStore(directory)).toEqual(persistedBefore) }) }) @@ -503,15 +482,14 @@ describe('restart reconciliation', () => { expect(record?.lease.provenHandleLinkId).toBe('link-1') }) - it('never applies a stale restart probe to a replacement owner', async () => { - const writer = await open() - await establishOwner(writer) - const reconciler = await open() + it('never applies a stale restart probe to a lease another reconcile already settled', async () => { + await establishOwner(await open()) + const store = await open() let releaseProbe!: (probe: AgentSessionOwnerProbe) => void let markProbeStarted!: () => void const probeStarted = new Promise((resolve) => (markProbeStarted = resolve)) const probeResult = new Promise((resolve) => (releaseProbe = resolve)) - const reconciliation = reconciler.reconcileOnRestart({ + const stale = store.reconcileOnRestart({ probe: async () => { markProbeStarted() return probeResult @@ -520,45 +498,16 @@ describe('restart reconciliation', () => { }) await probeStarted - await writer.evictProvenDeadOwner({ - sessionId: 'session-alpha', - expectedFence: 1, - probe: { outcome: 'pid-absent' }, - now: NOW + 100 - }) - const replacement = await writer.reserveOwner( - reserveRequest({ - expectedFence: 2, - probe: UNUSED, - spawnToken: 'spawn-b', - operation: { - callerKey: 'client-1', - operationId: operationId(NOW + 200), - fingerprint: 'fp-2' - }, - now: NOW + 200 - }) - ) - await writer.commitProcessIdentity({ - sessionId: 'session-alpha', - fence: replacement.record.lease.runtimeFence, - process: processIdentity({ pid: 5252, spawnToken: 'spawn-b' }), - now: NOW + 300 - }) - await writer.proveOwner({ - sessionId: 'session-alpha', - fence: replacement.record.lease.runtimeFence, - link: handleLink({ linkId: 'link-2', origin: 'resumed', mintedAtFence: 3 }), - now: NOW + 300 - }) + const settled = await store.reconcileOnRestart({ probe: async () => MATCHED, now: NOW + 500 }) + expect(settled.get('session-alpha')?.lease).toMatchObject({ claimStatus: 'live' }) releaseProbe({ outcome: 'pid-absent' }) - expect(await reconciliation).toEqual(new Map()) + expect(await stale).toEqual(new Map()) const persisted = await readPersistedTestAgentSessionStore(directory) expect(persisted.records['session-alpha'].lease).toMatchObject({ - runtimeFence: 3, + runtimeFence: 1, claimStatus: 'live', - ownerProcess: { pid: 5252, spawnToken: 'spawn-b' }, + ownerProcess: { pid: 4242, spawnToken: 'spawn-a' }, unreconciled: false }) }) @@ -757,7 +706,7 @@ describe('host and workspace isolation', () => { }) }) -describe('claim keys, checkpoints, and unreadable rows', () => { +describe('claim keys and unreadable rows', () => { it('keeps a retired claim key verifiable for the retention window', async () => { const store = await open() await store.retireClaimKey('key-1', NOW) @@ -770,40 +719,6 @@ describe('claim keys, checkpoints, and unreadable rows', () => { expect(store.isClaimKeyVerifiable('key-unknown', NOW)).toBe(true) }) - it('refuses a journal checkpoint that moves backwards', async () => { - const store = await open() - await establishOwner(store) - await store.setJournalCheckpoint({ - sessionId: 'session-alpha', - fence: 1, - checkpoint: { epoch: 2, sequence: 10 }, - now: NOW - }) - await expect( - store.setJournalCheckpoint({ - sessionId: 'session-alpha', - fence: 1, - checkpoint: { epoch: 2, sequence: 9 }, - now: NOW - }) - ).rejects.toThrow('agent_session_checkpoint_stale') - await expect( - store.setJournalCheckpoint({ - sessionId: 'session-alpha', - fence: 1, - checkpoint: { epoch: 1, sequence: 999 }, - now: NOW - }) - ).rejects.toThrow('agent_session_checkpoint_stale') - const advanced = await store.setJournalCheckpoint({ - sessionId: 'session-alpha', - fence: 1, - checkpoint: { epoch: 3, sequence: 0 }, - now: NOW - }) - expect(advanced.lease.journalCheckpoint).toEqual({ epoch: 3, sequence: 0 }) - }) - it.each([ [ 'invalid checkpoint', @@ -824,55 +739,15 @@ describe('claim keys, checkpoints, and unreadable rows', () => { expect((await open()).isSessionUnreadable('session-alpha')).toBe(true) }) - it('recovers the previous committed state when the primary file is corrupt', async () => { - const first = await open() - await establishOwner(first) - // A second commit leaves the first as the backup. - await first.setJournalCheckpoint({ - sessionId: 'session-alpha', - fence: 1, - checkpoint: { epoch: 1, sequence: 1 }, - now: NOW - }) - await writeFile(testAgentSessionStoreFilePath(directory), '{ truncated') - - const reopened = await open() - expect(reopened.recoveredFromBackup).toBe(true) - expect(reopened.getRecord('session-alpha')?.lease.runtimeFence).toBe(1) - - // The next transaction completes. It used to reject forever: the latch that guarded against - // the lost commit's fence had no exit, so a profile in this state could never write again. - await expect(reopened.retireClaimKey('key-2', NOW)).resolves.not.toThrow() - // Safety is kept by recording a FLOOR the next grant must clear, not by rewriting the current - // fence: `live` means a handle proven at exactly that number, so moving it would invalidate the - // record. The floor dominates the highest fence the lost commit could have granted (1 + 1). - const recovered = reopened.getRecord('session-alpha') - expect(recovered?.lease.runtimeFence).toBe(1) - expect(recovered?.lease.minimumNextFence).toBe(3) - }) - - it.each([ - ['corrupt', ['{ truncated']], - ['missing required collections', ['{"schemaVersion":1,"hostId":"local"}']], - ['invalid operation row', [BAD_OP_STORE]], - ['invalid retired key', [BAD_KEY_STORE]], - ['corrupt in both committed copies', ['{ truncated', '{ also truncated']] - ])('fails closed when the store is %s', async (_name, copies) => { - const filePath = testAgentSessionStoreFilePath(directory) - await mkdir(dirname(filePath), { recursive: true }) - await writeFile(filePath, copies[0]) - if (copies[1]) { - await writeFile(`${filePath}.bak`, copies[1]) - } - await expect(open()).rejects.toThrow('agent_session_store_corrupt') - }) - - it('refuses to write a store written by a newer schema', async () => { + it('refuses to write a store a newer Orca wrote, with the refusal clients print as "update"', async () => { await seedTestAgentSessionStoreFromNewerBuild(directory) const store = await open() expect(store.readOnly).toBe(true) - await expect(store.reserveOwner(reserveRequest())).rejects.toThrow( - 'agent_session_legacy_required' - ) + await expect(store.reserveOwner(reserveRequest())).rejects.toMatchObject({ + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalWrittenByNewerOrca' } + } + }) }) }) diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 925fa38d8d18..9f1c5bf6e7f9 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -1,7 +1,13 @@ +/** Durable single-writer session records and their operation ledger, as rows in the host's chat + * journal database. */ + import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' -import { commitConversationCommandRecord } from './agent-session-conversation-command-record' +import { + commitConversationClearRecord, + commitConversationCommandRecord, + type AgentSessionConversationClear +} from './agent-session-conversation-command-record' import { setAgentSessionRecordConversationName } from './agent-session-record-conversation-name' -/** Durable single-writer session records and their operation ledger. */ import { agentSessionOperationKey, @@ -29,7 +35,6 @@ import type { AgentSessionProviderHandleLink } from '../../shared/agent-session- import { agentSessionScopeKey, type AgentSessionExecutionLocation, - type AgentSessionJournalCheckpoint, type AgentSessionOptionsReplacement, type AgentSessionRecord } from '../../shared/agent-session-record' @@ -37,7 +42,6 @@ import { commitAgentSessionProcessIdentity, evictAgentSessionOwner, proveAgentSessionOwner, - setAgentSessionJournalCheckpoint, type AgentSessionProcessIdentityCommit } from './agent-session-lease-transitions' import { @@ -61,17 +65,11 @@ import { type AgentSessionReserveRequest, type AgentSessionReserveResult } from './agent-session-reservation-admission' -import { - agentSessionStoreRevision, - agentSessionStorePath, - type AgentSessionStoreState -} from './agent-session-record-store-file' -import { setAgentSessionTabVisibility } from './agent-session-tab-table' -import { loadProtectedAgentSessionStore } from './agent-session-record-store-security' -import { - AgentSessionStoreTransactionQueue, - markAgentSessionStoreLeasesUnreconciled -} from './agent-session-store-transaction-queue' +import type { AgentSessionStoreState } from './agent-session-record-store-file' +import { setAgentSessionTabVisibility, showAgentSessionTabs } from './agent-session-tab-table' +import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import { loadAgentSessionStoreRows } from './agent-session-record-rows' +import { AgentSessionStoreTransactions } from './agent-session-store-transactions' export const AGENT_SESSION_LEASE_TTL_MS = 30_000, AGENT_SESSION_LEASE_RENEW_INTERVAL_MS = 10_000 @@ -79,46 +77,32 @@ export const AGENT_SESSION_LEASE_TTL_MS = 30_000, export class AgentSessionRecordStore { private readonly deathEvidenceListeners = new Set<(sessionId: string) => void>() - private constructor(private readonly transactions: AgentSessionStoreTransactionQueue) {} - - static async open(args: { directory: string; hostId: string }): Promise { - const filePath = agentSessionStorePath(args.directory) - const loaded = await loadProtectedAgentSessionStore(filePath, args.hostId) - // Why: every persisted lease is unreconciled until this host adjudicates it, so a restart - // grants no writer on the strength of what the previous process wrote. - const diskRevision = agentSessionStoreRevision(loaded.state) - // The normalized legacy leases reach disk with this store's first transaction rather than a - // write here: a rewrite at open would read as an external change to any other holder of the - // file mid-restart. - markAgentSessionStoreLeasesUnreconciled(loaded.state) - const transactions = AgentSessionStoreTransactionQueue.fromLoadedStore( - filePath, - args.hostId, - { ...loaded, needsRewrite: loaded.needsRewrite || loaded.legacyHandoffLeasesNormalized }, - diskRevision + private constructor( + private readonly transactions: AgentSessionStoreTransactions, + readonly hostId: string + ) {} + + /** Reads every row once; nothing re-reads them. `hostId` is the execution host this runtime is. */ + static open(args: { + journalDatabase: JournalHostDatabase + hostId: string + }): AgentSessionRecordStore { + const loaded = loadAgentSessionStoreRows(args.journalDatabase.db, args.hostId) + return new AgentSessionRecordStore( + new AgentSessionStoreTransactions(args.journalDatabase, loaded), + args.hostId ) - if (loaded.needsRewrite && !loaded.readOnly && !loaded.recoveredFromBackup) { - await transactions.persistLoadedRewrite() - } - return new AgentSessionRecordStore(transactions) } private get state(): AgentSessionStoreState { return this.transactions.state } + /** A newer Orca wrote the database: every read answers, and every write is refused. */ get readOnly(): boolean { return this.transactions.readOnly } - get recoveredFromBackup(): boolean { - return this.transactions.recoveredFromBackup - } - - get hostId(): string { - return this.state.hostId - } - getRecord = (sessionId: string): AgentSessionRecord | null => this.state.records.get(sessionId) ?? null @@ -129,9 +113,14 @@ export class AgentSessionRecordStore { this.state.records.has(sessionId) ) + /** Unrecorded, `sessionIds` are the tab rows a chat opened while the import was owed left. */ getVisibleSessionTabIndex = (): { present: boolean; sessionIds: string[] } => ({ present: this.state.sessionTabs !== null, - sessionIds: this.listVisibleSessionIds() + sessionIds: this.state.sessionTabs + ? this.listVisibleSessionIds() + : (this.state.unrecordedSessionTabs?.sessionIds() ?? []).filter((sessionId) => + this.state.records.has(sessionId) + ) }) /** The id of the chat tab showing this conversation, if one does. */ @@ -143,7 +132,13 @@ export class AgentSessionRecordStore { * Showing keeps a tab the session already has; `tabId` puts a hidden one back under its old id. */ setSessionTabVisibility(sessionId: string, visible: boolean, tabId?: string): Promise { - return this.transact(() => setAgentSessionTabVisibility(this.state, sessionId, visible, tabId)) + return this.transact((draft) => setAgentSessionTabVisibility(draft, sessionId, visible, tabId)) + } + + /** Shows each session that still has a record, in one write: an index written part way would + * read as complete at the next launch and drop the rest. */ + showSessionTabs(sessionIds: readonly string[]): Promise { + return this.transact((draft) => showAgentSessionTabs(draft, sessionIds)) } listByScope(location: AgentSessionExecutionLocation): AgentSessionRecord[] { @@ -156,11 +151,15 @@ export class AgentSessionRecordStore { fence: number, command: NonNullable ): Promise { - return this.transact(() => - commitConversationCommandRecord(this.state, sessionId, fence, command) + return this.transact((draft) => + commitConversationCommandRecord(draft, sessionId, fence, command) ) } + /** A committed /clear and the at-rest conversation it continues in, in one write. */ + commitConversationClear = (clear: AgentSessionConversationClear): Promise => + this.transact((draft) => commitConversationClearRecord(draft, clear)) + /** Unfenced on purpose: the name is a durable note, so writing it never contends with the * writer lease. `null` clears it. */ setConversationName = (sessionId: string, name: string | null): Promise => @@ -182,8 +181,8 @@ export class AgentSessionRecordStore { isAgentSessionClaimKeyVerifiable(this.state, keyId, now) async reserveOwner(request: AgentSessionReserveRequest): Promise { - return this.transact(() => - commitAgentSessionReservation(this.state, request, AGENT_SESSION_LEASE_TTL_MS) + return this.transact((draft) => + commitAgentSessionReservation(draft, request, AGENT_SESSION_LEASE_TTL_MS) ) } @@ -219,11 +218,11 @@ export class AgentSessionRecordStore { /** Settle the failed attach and its reservation in one durable transaction. */ settleFailedAcquisition = (args: AgentSessionFailedAcquisitionSettlement) => - this.transact(() => settleFailedAgentSessionAcquisition(this.state, args)) + this.transact((draft) => settleFailedAgentSessionAcquisition(draft, args)) settleFailedPostAcquisitionAttachment = ( args: AgentSessionFailedPostAcquisitionAttachmentSettlement - ) => this.transact(() => settleFailedAgentSessionPostAcquisitionAttachment(this.state, args)) + ) => this.transact((draft) => settleFailedAgentSessionPostAcquisitionAttachment(draft, args)) async renewLease(args: AgentSessionLeaseRenewal): Promise { const [renewed] = await this.renewLeases([args]) @@ -231,8 +230,8 @@ export class AgentSessionRecordStore { } async renewLeases(renewals: readonly AgentSessionLeaseRenewal[]): Promise { - return this.transact(() => - renewAgentSessionLeases(this.state, renewals, AGENT_SESSION_LEASE_TTL_MS) + return this.transact((draft) => + renewAgentSessionLeases(draft, renewals, AGENT_SESSION_LEASE_TTL_MS) ) } @@ -252,38 +251,33 @@ export class AgentSessionRecordStore { return this.mutate(sessionId, transition) } - async setJournalCheckpoint(args: { - sessionId: string - fence: number - checkpoint: AgentSessionJournalCheckpoint - now: number - }): Promise { - return this.mutate(args.sessionId, (record) => - setAgentSessionJournalCheckpoint({ ...args, record }) - ) - } - - /** Adjudicate every lease this host loaded. No lease grants a writer until it appears here. */ + /** + * Adjudicate every lease this host loaded. No lease grants a writer until it appears here. On a + * database a newer Orca wrote, the verdicts are kept in memory only: they are re-derived at every + * start, and none grants a writer there, since every grant is a write. + */ async reconcileOnRestart( args: AgentSessionRestartProbeArgs ): Promise> { const pending = this.listRecords().filter((record) => record.lease.unreconciled) const probes = await collectAgentSessionRestartProbes(pending, args) - return this.transact(() => applyAgentSessionRestartProbes(this.state, probes, args.now)) + return this.transact((draft) => applyAgentSessionRestartProbes(draft, probes, args.now), { + inMemoryWhenReadOnly: true + }) } /** Admits one non-reservation mutation through the durable ledger. */ admitOperation = (args: AgentSessionOperationAdmission): Promise => - this.transact(() => admitAgentSessionOperationInto(this.state, args)) + this.transact((draft) => admitAgentSessionOperationInto(draft, args)) /** Send ids stay global after a caller reconnects under a different identity. */ admitGlobalOperation = ( args: AgentSessionOperationAdmission ): Promise => - this.transact(() => admitAgentSessionGlobalOperationInto(this.state, args)) + this.transact((draft) => admitAgentSessionGlobalOperationInto(draft, args)) admitMutationOperation = (args: AgentSessionMutationOperationAdmission) => - this.transact(() => admitAgentSessionMutationOperation(this.state, args)) + this.transact((draft) => admitAgentSessionMutationOperation(draft, args)) /** The ledger's answer alone, placing nothing; `admitMutationOperation` is the transaction. */ evaluateMutationOperation = (args: AgentSessionMutationOperationAdmission) => @@ -296,36 +290,36 @@ export class AgentSessionRecordStore { callerKey: string operationId: string }): Promise => - this.transact(() => claimAgentSessionOperationInto(this.state, args)) + this.transact((draft) => claimAgentSessionOperationInto(draft, args)) async recordOperationOutcome(args: { callerKey?: string operationId: string outcome: AgentSessionOperationOutcome }): Promise { - await this.transact(() => settleAgentSessionOperationInto(this.state, args)) + await this.transact((draft) => settleAgentSessionOperationInto(draft, args)) } replaceSessionOptions = (args: AgentSessionOptionsReplacement): Promise => this.mutate(args.sessionId, (record) => replaceAgentSessionRecordOptions(record, args)) async retireClaimKey(keyId: string, now: number): Promise { - await this.transact(() => retireAgentSessionClaimKey(this.state, keyId, now)) + await this.transact((draft) => retireAgentSessionClaimKey(draft, keyId, now)) } private async mutate( sessionId: string, apply: (record: AgentSessionRecord) => AgentSessionRecord ): Promise { - return this.transact(() => { - const record = this.state.records.get(sessionId) + return this.transact((draft) => { + const record = draft.records.get(sessionId) if (!record) { - throw this.isSessionUnreadable(sessionId) + throw draft.unreadableRecords.has(sessionId) ? agentSessionRefusalError('execution_owner_reconciling', { reason: 'recordUnreadable' }) : agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) } const next = apply(record) - this.state.records.set(sessionId, next) + draft.records.set(sessionId, next) return next }) } @@ -337,22 +331,26 @@ export class AgentSessionRecordStore { return () => this.deathEvidenceListeners.delete(listener) } - /** Serialize every mutation against the latest committed disk state. */ - private transact = async (apply: () => T): Promise => { + /** Serializes every mutation. `apply` changes only the draft it is given; readers see the change + * once its rows have committed. */ + private transact = async ( + apply: (draft: AgentSessionStoreState) => T, + options?: { inMemoryWhenReadOnly?: boolean } + ): Promise => { let proven: string[] = [] - const result = await this.transactions.transact(() => { + const result = await this.transactions.transact((draft) => { if (this.deathEvidenceListeners.size === 0) { - return apply() + return apply(draft) } const before = new Map( - [...this.state.records].map(([id, record]) => [id, record.lease.deathEvidence]) + [...draft.records].map(([id, record]) => [id, record.lease.deathEvidence]) ) - const applied = apply() - proven = [...this.state.records] + const applied = apply(draft) + proven = [...draft.records] .filter(([id, { lease }]) => lease.deathEvidence && lease.deathEvidence !== before.get(id)) .map(([id]) => id) return applied - }) + }, options) for (const sessionId of proven) { this.deathEvidenceListeners.forEach((listener) => listener(sessionId)) } diff --git a/src/main/runtime/agent-session-record-unsupported-schema.test.ts b/src/main/runtime/agent-session-record-unsupported-schema.test.ts deleted file mode 100644 index dd0a9dcd28d7..000000000000 --- a/src/main/runtime/agent-session-record-unsupported-schema.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' -import { AgentSessionRecordStore } from './agent-session-record-store' -import { - AGENT_SESSION_STORE_SCHEMA_VERSION, - agentSessionStorePath -} from './agent-session-record-store-file' -import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' - -const NOW = 1_800_000_000_000 -const SESSION_ID = 'session-alpha-1' -let directory: string - -function reserveRequest(): AgentSessionReserveRequest { - return { - sessionId: SESSION_ID, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' - }, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/home/user/.codex' }, - expectedFence: null, - spawnToken: 'spawn-new', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' }, - operation: { - callerKey: 'client-1', - operationId: `${NOW}-00000000000000000000000000000001`, - fingerprint: 'fp-1' - }, - now: NOW - } -} - -beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-unsupported-')) -}) - -afterEach(async () => { - await rm(directory, { recursive: true, force: true }) -}) - -describe('unsupported agent session record schema', () => { - it('quarantines without upgrading and keeps the session fail-closed', async () => { - const filePath = agentSessionStorePath(directory) - const unsupported = { ...agentSessionRecordFixture(), schemaVersion: 1 } - const payload = JSON.stringify({ - schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, - hostId: 'local', - records: { [SESSION_ID]: unsupported }, - operations: {}, - retiredClaimKeys: [], - unusableRecords: {} - }) - await Promise.all([writeFile(filePath, payload), writeFile(`${filePath}.bak`, payload)]) - - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) - - expect(store.getRecord(SESSION_ID)).toBeNull() - expect(store.isSessionUnreadable(SESSION_ID)).toBe(true) - await expect(store.reserveOwner(reserveRequest())).rejects.toThrow( - 'execution_owner_reconciling' - ) - const persisted = JSON.parse(await readFile(filePath, 'utf-8')) - expect(persisted.records).not.toHaveProperty(SESSION_ID) - expect(persisted.unusableRecords[SESSION_ID]).toMatchObject({ - reason: 'unsupported_schema', - raw: { schemaVersion: 1 } - }) - }) - - it('rejects an ad-hoc store schema without rewriting it', async () => { - const filePath = agentSessionStorePath(directory) - const payload = JSON.stringify({ - schemaVersion: 1, - hostId: 'local', - records: {}, - operations: {}, - retiredClaimKeys: [], - unusableRecords: {} - }) - await writeFile(filePath, payload) - - await expect(AgentSessionRecordStore.open({ directory, hostId: 'local' })).rejects.toThrow( - 'agent_session_store_corrupt' - ) - await expect(readFile(filePath, 'utf-8')).resolves.toBe(payload) - }) -}) diff --git a/src/main/runtime/agent-session-recovery-publish-fault.test.ts b/src/main/runtime/agent-session-recovery-publish-fault.test.ts deleted file mode 100644 index 9fdbe393334f..000000000000 --- a/src/main/runtime/agent-session-recovery-publish-fault.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as DurableFileWrite from '../durable-file-write' -import { - agentSessionStoreRevision, - loadAgentSessionStore, - saveAgentSessionStore, - type AgentSessionStoreState -} from './agent-session-record-store-file' -import { AgentSessionStoreTransactionQueue } from './agent-session-store-transaction-queue' - -const publishFault = vi.hoisted(() => ({ armed: false })) - -vi.mock('../durable-file-write', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - renameDurable: async (tmpPath: string, finalPath: string) => { - if (publishFault.armed) { - publishFault.armed = false - throw new Error('simulated death before primary publish') - } - return actual.renameDurable(tmpPath, finalPath) - } - } -}) - -let root: string -let storePath: string - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-store-recovery-publish-')) - storePath = join(root, 'agent-sessions.json') -}) - -afterEach(async () => { - publishFault.armed = false - await rm(root, { recursive: true, force: true }) -}) - -function state(generation: number): AgentSessionStoreState { - return { - schemaVersion: 2, - hostId: 'local', - records: new Map(), - operations: new Map(), - retiredClaimKeys: [{ keyId: `generation-${generation}`, retiredAt: generation }], - unreadableRecords: new Map(), - sessionTabs: null - } -} - -describe('backup recovery publication', () => { - it('keeps the valid backup when publication fails after a corrupt primary was recovered', async () => { - await saveAgentSessionStore(storePath, state(1), { primaryStatus: 'unusable-or-absent' }) - await saveAgentSessionStore(storePath, state(2), { - primaryStatus: 'validated' - }) - await writeFile(storePath, '{corrupt-primary', 'utf-8') - const knownGoodBackup = await readFile(`${storePath}.bak`, 'utf-8') - - const recovered = await loadAgentSessionStore(storePath, 'local') - expect(recovered.recoveredFromBackup).toBe(true) - expect(recovered.state.retiredClaimKeys[0]?.keyId).toBe('generation-1') - const queue = AgentSessionStoreTransactionQueue.fromLoadedStore( - storePath, - 'local', - recovered, - agentSessionStoreRevision(recovered.state) - ) - - publishFault.armed = true - await expect(queue.persistLoadedRewrite()).rejects.toThrow( - 'simulated death before primary publish' - ) - - expect(await readFile(`${storePath}.bak`, 'utf-8')).toBe(knownGoodBackup) - const afterFault = await loadAgentSessionStore(storePath, 'local') - expect(afterFault.recoveredFromBackup).toBe(true) - expect(afterFault.state.retiredClaimKeys[0]?.keyId).toBe('generation-1') - }) -}) diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index d86383ddae72..db5707d3b4d8 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -25,7 +25,6 @@ import { type AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import { - AGENT_SESSION_RECORD_SCHEMA_VERSION, agentSessionExecutionLocationsEqual, isAgentSessionLaunchEnv, isAgentSessionOptions, @@ -47,6 +46,7 @@ import { type AgentSessionReservation } from './agent-session-lease-transitions' import type { AgentSessionStoreState } from './agent-session-record-store-file' +import { agentSessionRecordIdentityFields } from './agent-session-record-founding' export type AgentSessionReserveRequest = { sessionId: string @@ -278,18 +278,10 @@ function createAgentSessionRecord( reservation: AgentSessionReservation ): AgentSessionRecord { return { - schemaVersion: AGENT_SESSION_RECORD_SCHEMA_VERSION, - sessionId: request.sessionId, - location: request.location, - provider: request.provider, + ...agentSessionRecordIdentityFields(request, request.now), // Fence 1 below is this record's first, and the owner probe requires the head link to carry the // record's current fence — so an adopted link must be minted at that same fence. providerHandleChain: request.adoptedHandleLink ? [request.adoptedHandleLink] : [], - accountHome: request.accountHome, - ...(request.options ? { options: { ...request.options } } : {}), - ...(request.launchArgs ? { launchArgs: [...request.launchArgs] } : {}), - createdAt: request.now, - updatedAt: request.now, lease: { sessionId: request.sessionId, runtimeKind: 'native', diff --git a/src/main/runtime/agent-session-store-draft.ts b/src/main/runtime/agent-session-store-draft.ts new file mode 100644 index 000000000000..2afdfc13484b --- /dev/null +++ b/src/main/runtime/agent-session-store-draft.ts @@ -0,0 +1,135 @@ +/** + * A transaction applies to a draft of the published store state, and the store publishes the draft + * only once its rows have committed, so no reader ever sees a change that might still roll back. + */ + +import type { + AgentSessionStoreState, + RetiredAgentSessionClaimKey +} from './agent-session-record-store-file' +import { + isReadableAgentSessionStoreOperation, + isReadableAgentSessionStoreRecord, + isReadableAgentSessionStoreTab, + isReadableRetiredAgentSessionClaimKey +} from './agent-session-store-row-rules' +import type { PersistedAgentSessionTab } from './agent-session-tab-table' + +/** Unreadable rows are derived at load and never written, so the draft shares them. */ +export function draftAgentSessionStoreState(state: AgentSessionStoreState): AgentSessionStoreState { + return { + ...state, + records: new Map(state.records), + operations: new Map(state.operations), + retiredClaimKeys: [...state.retiredClaimKeys], + sessionTabs: state.sessionTabs?.clone() ?? null + } +} + +type KeyedRowWrites = { upsert: [key: string, json: string][]; remove: string[] } + +/** Exactly the rows a draft changed, each serialized as it will be stored. */ +export type AgentSessionStoreRowWrites = { + records: KeyedRowWrites + operations: KeyedRowWrites + /** The whole list when it changed, else null. */ + retiredClaimKeys: RetiredAgentSessionClaimKey[] | null + /** The whole index when it changed: `recorded: false` is a store that has never recorded one. */ + sessionTabs: { recorded: boolean; tabs: PersistedAgentSessionTab[] } | null +} + +/** A row as a load parses it back: JSON drops `undefined` members that an in-memory check sees. */ +function serializeChangedRows( + published: ReadonlyMap, + next: ReadonlyMap, + readable: (key: string, written: unknown) => boolean +): KeyedRowWrites { + const writes: KeyedRowWrites = { upsert: [], remove: [] } + let added = 0 + for (const [key, value] of next) { + const prior = published.get(key) + if (prior === value) { + continue + } + const json = JSON.stringify(value) + if (json === undefined || !readable(key, JSON.parse(json))) { + throw new Error('agent_session_store_write_invalid') + } + writes.upsert.push([key, json]) + added += prior === undefined && !published.has(key) ? 1 : 0 + } + // Only a size mismatch can hide a removal, so the common write never walks the published rows. + if (published.size + added !== next.size) { + for (const key of published.keys()) { + if (!next.has(key)) { + writes.remove.push(key) + } + } + } + return writes +} + +function retiredClaimKeysChanged( + published: readonly RetiredAgentSessionClaimKey[], + next: readonly RetiredAgentSessionClaimKey[] +): boolean { + return published.length !== next.length || published.some((entry, index) => entry !== next[index]) +} + +/** + * The rows a draft changed relative to the published state, by identity, or null when it changed + * none. Each changed row is checked as written against the rules a load applies, so a row a later + * load would refuse rejects its own transaction instead of living in memory unreadable. + */ +export function agentSessionStoreDraftRowWrites( + published: AgentSessionStoreState, + draft: AgentSessionStoreState +): AgentSessionStoreRowWrites | null { + const records = serializeChangedRows(published.records, draft.records, (sessionId, written) => + isReadableAgentSessionStoreRecord(sessionId, written) + ) + const operations = serializeChangedRows(published.operations, draft.operations, (key, written) => + isReadableAgentSessionStoreOperation(key, written) + ) + const retiredClaimKeys = retiredClaimKeysChanged( + published.retiredClaimKeys, + draft.retiredClaimKeys + ) + ? draft.retiredClaimKeys + : null + if ( + retiredClaimKeys && + (!retiredClaimKeys.every((entry) => + isReadableRetiredAgentSessionClaimKey(JSON.parse(JSON.stringify(entry))) + ) || + new Set(retiredClaimKeys.map((entry) => entry.keyId)).size !== retiredClaimKeys.length) + ) { + throw new Error('agent_session_store_write_invalid') + } + const tabsChanged = + published.sessionTabs && draft.sessionTabs + ? !published.sessionTabs.equals(draft.sessionTabs) + : published.sessionTabs !== draft.sessionTabs + const tabs = tabsChanged + ? (draft.sessionTabs?.entries() ?? []).map(([tabId, sessionId]) => ({ tabId, sessionId })) + : [] + if (!tabs.every(isReadableAgentSessionStoreTab)) { + throw new Error('agent_session_store_write_invalid') + } + const changed = + records.upsert.length > 0 || + records.remove.length > 0 || + operations.upsert.length > 0 || + operations.remove.length > 0 || + retiredClaimKeys !== null || + tabsChanged + if (!changed) { + return null + } + return { + records, + operations, + retiredClaimKeys, + sessionTabs: tabsChanged ? { recorded: draft.sessionTabs !== null, tabs } : null + } +} diff --git a/src/main/runtime/agent-session-store-row-rules.ts b/src/main/runtime/agent-session-store-row-rules.ts new file mode 100644 index 000000000000..6d2f4ddf6b2e --- /dev/null +++ b/src/main/runtime/agent-session-store-row-rules.ts @@ -0,0 +1,63 @@ +/** + * What makes one agent-session store row readable. A load applies these to every row it reads; a + * transaction applies them to every row it changed before committing, so the rows this build writes + * are exactly the rows a later load accepts. + */ + +import { + agentSessionOperationKey, + isAgentSessionOperationRow, + type AgentSessionOperationRow +} from '../../shared/agent-session-operation-ledger' +import { isAgentSessionId, isPersistedAgentSessionRecord } from '../../shared/agent-session-record' +import type { PersistedAgentSessionRecord } from '../../shared/agent-session-legacy-handoff-lease' +import { isAgentSessionSurfaceTabId } from '../../shared/agent-session-surface-tab-id' +import type { RetiredAgentSessionClaimKey } from './agent-session-record-store-file' +import type { PersistedAgentSessionTab } from './agent-session-tab-table' + +/** A record row a load keeps in `records` rather than quarantining. */ +export function isReadableAgentSessionStoreRecord( + sessionId: string, + value: unknown +): value is PersistedAgentSessionRecord { + return isPersistedAgentSessionRecord(value) && value.sessionId === sessionId +} + +export function isReadableAgentSessionStoreOperation( + key: string, + value: unknown +): value is AgentSessionOperationRow { + return ( + isAgentSessionOperationRow(value) && + key === agentSessionOperationKey(value.callerKey, value.operationId) + ) +} + +export function isReadableRetiredAgentSessionClaimKey( + entry: unknown +): entry is RetiredAgentSessionClaimKey { + return ( + typeof entry === 'object' && + entry !== null && + 'keyId' in entry && + typeof entry.keyId === 'string' && + entry.keyId.length > 0 && + entry.keyId.length <= 512 && + 'retiredAt' in entry && + typeof entry.retiredAt === 'number' && + Number.isSafeInteger(entry.retiredAt) && + entry.retiredAt >= 0 + ) +} + +/** One chat tab entry; a table is readable when every entry is and no id repeats. */ +export function isReadableAgentSessionStoreTab(entry: unknown): entry is PersistedAgentSessionTab { + return ( + typeof entry === 'object' && + entry !== null && + 'tabId' in entry && + isAgentSessionSurfaceTabId(entry.tabId) && + 'sessionId' in entry && + isAgentSessionId(entry.sessionId) + ) +} diff --git a/src/main/runtime/agent-session-store-serialization.ts b/src/main/runtime/agent-session-store-serialization.ts deleted file mode 100644 index adc93f85c011..000000000000 --- a/src/main/runtime/agent-session-store-serialization.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { AgentSessionStoreState } from './agent-session-record-store-file' -import { serializeAgentSessionTabTable } from './agent-session-tab-table' - -export function serializeAgentSessionStoreState(state: AgentSessionStoreState): string { - const records: Record = Object.create(null) - for (const [sessionId, record] of state.records) { - records[sessionId] = record - } - const serialized: Record = { - schemaVersion: state.schemaVersion, - hostId: state.hostId, - records, - operations: Object.fromEntries(state.operations), - retiredClaimKeys: state.retiredClaimKeys, - unusableRecords: Object.fromEntries(state.unreadableRecords) - } - if (state.sessionTabs) { - Object.assign(serialized, serializeAgentSessionTabTable(state.sessionTabs)) - } - return JSON.stringify(serialized) -} diff --git a/src/main/runtime/agent-session-store-transaction-queue.ts b/src/main/runtime/agent-session-store-transaction-queue.ts deleted file mode 100644 index 1a4c78e46720..000000000000 --- a/src/main/runtime/agent-session-store-transaction-queue.ts +++ /dev/null @@ -1,191 +0,0 @@ -import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' -import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' -import { raiseAgentSessionFencesAfterBackupRecovery } from './agent-session-backup-recovery-fence' -import { - AGENT_SESSION_STORE_SCHEMA_VERSION, - agentSessionStoreRevision, - loadAgentSessionStore, - saveAgentSessionStore, - type AgentSessionStoreState, - type LoadedAgentSessionStore -} from './agent-session-record-store-file' -import { withFileTransactionLock } from '../file-transaction-lock' - -/** Latch fields older builds wrote. Nothing reads them, and dropping them keeps a lease this build - * writes back from carrying a stale latch to an older build after a downgrade. */ -type RetiredAgentSessionLeaseFields = { - processlessAt?: unknown - settlementRetryRequired?: unknown - settlementRetryId?: unknown -} - -function markLoadedLeasesUnreconciled(state: AgentSessionStoreState): void { - for (const [sessionId, record] of state.records) { - const lease: AgentSessionLease & RetiredAgentSessionLeaseFields = record.lease - const { - processlessAt: _processlessAt, - settlementRetryRequired: _settlementRetryRequired, - settlementRetryId: _settlementRetryId, - ...current - } = lease - state.records.set(sessionId, { - ...record, - lease: { ...current, unreconciled: true } - }) - } -} - -function mapEntriesMatch(left: ReadonlyMap, right: ReadonlyMap): boolean { - if (left.size !== right.size) { - return false - } - for (const [key, value] of left) { - if (right.get(key) !== value) { - return false - } - } - return true -} - -function agentSessionStoreStateChanged( - state: AgentSessionStoreState, - records: ReadonlyMap, - operations: ReadonlyMap, - retiredClaimKeys: AgentSessionStoreState['retiredClaimKeys'], - unreadableRecords: AgentSessionStoreState['unreadableRecords'], - sessionTabs: AgentSessionStoreState['sessionTabs'] -): boolean { - return ( - !mapEntriesMatch(state.records, records) || - !mapEntriesMatch(state.operations, operations) || - !mapEntriesMatch(state.unreadableRecords, unreadableRecords) || - (state.sessionTabs && sessionTabs - ? !state.sessionTabs.equals(sessionTabs) - : state.sessionTabs !== sessionTabs) || - state.retiredClaimKeys.length !== retiredClaimKeys.length || - state.retiredClaimKeys.some((entry, index) => entry !== retiredClaimKeys[index]) - ) -} - -export class AgentSessionStoreTransactionQueue { - private queue: Promise = Promise.resolve() - private diskRecoveredFromBackup: boolean - - constructor( - private readonly filePath: string, - readonly hostId: string, - readonly readOnly: boolean, - readonly recoveredFromBackup: boolean, - private diskStoreFound: boolean, - public state: AgentSessionStoreState, - private diskRevision: string, - private needsRewrite: boolean - ) { - this.diskRecoveredFromBackup = recoveredFromBackup - } - - static fromLoadedStore( - filePath: string, - hostId: string, - loaded: LoadedAgentSessionStore, - diskRevision: string - ): AgentSessionStoreTransactionQueue { - return new AgentSessionStoreTransactionQueue( - filePath, - hostId, - loaded.readOnly, - loaded.recoveredFromBackup, - loaded.storeFound, - loaded.state, - diskRevision, - loaded.needsRewrite - ) - } - - transact(apply: () => T): Promise { - const run = this.queue.then(() => - withFileTransactionLock(this.filePath, async () => { - if (this.readOnly) { - throw new Error('agent_session_legacy_required') - } - await this.refreshExternallyChangedState() - const records = new Map(this.state.records) - const operations = new Map(this.state.operations) - const retiredClaimKeys = [...this.state.retiredClaimKeys] - const unreadableRecords = new Map(this.state.unreadableRecords) - const sessionTabs = this.state.sessionTabs?.clone() ?? null - try { - // The lost commit may have granted a higher fence than the backup records show. Rather - // than refuse forever, raise every recovered fence clear of anything that commit could - // have minted, then continue in the same transaction. - const recovering = this.diskRecoveredFromBackup - if (recovering) { - raiseAgentSessionFencesAfterBackupRecovery(this.state) - } - const result = apply() - if ( - !recovering && - !this.needsRewrite && - !agentSessionStoreStateChanged( - this.state, - records, - operations, - retiredClaimKeys, - unreadableRecords, - sessionTabs - ) - ) { - return result - } - await saveAgentSessionStore(this.filePath, this.state, { - primaryStatus: this.diskStoreFound && !recovering ? 'validated' : 'unusable-or-absent' - }) - this.state.schemaVersion = AGENT_SESSION_STORE_SCHEMA_VERSION - this.diskRevision = agentSessionStoreRevision(this.state) - this.diskRecoveredFromBackup = false - this.diskStoreFound = true - this.needsRewrite = false - return result - } catch (error) { - this.state.records = records - this.state.operations = operations - this.state.retiredClaimKeys = retiredClaimKeys - this.state.unreadableRecords = unreadableRecords - this.state.sessionTabs = sessionTabs - throw error - } - }) - ) - this.queue = run.catch(() => {}) - return run - } - - persistLoadedRewrite(): Promise { - return this.transact(() => undefined) - } - - private async refreshExternallyChangedState(): Promise { - const loaded = await loadAgentSessionStore(this.filePath, this.hostId) - if (this.diskStoreFound && !loaded.storeFound) { - throw new Error('agent_session_store_corrupt') - } - this.diskStoreFound ||= loaded.storeFound - const diskRevision = agentSessionStoreRevision(loaded.state) - this.diskRecoveredFromBackup = loaded.recoveredFromBackup - if (diskRevision === this.diskRevision) { - this.needsRewrite ||= loaded.needsRewrite - return - } - if (loaded.readOnly) { - throw new Error('agent_session_legacy_required') - } - markLoadedLeasesUnreconciled(loaded.state) - this.state = loaded.state - this.diskRevision = diskRevision - this.needsRewrite = loaded.needsRewrite - } -} - -export function markAgentSessionStoreLeasesUnreconciled(state: AgentSessionStoreState): void { - markLoadedLeasesUnreconciled(state) -} diff --git a/src/main/runtime/agent-session-store-transactions.test.ts b/src/main/runtime/agent-session-store-transactions.test.ts new file mode 100644 index 000000000000..5ab6cad9e028 --- /dev/null +++ b/src/main/runtime/agent-session-store-transactions.test.ts @@ -0,0 +1,201 @@ +// How a record-store write reaches the chat database: only the rows it changed, each checked with +// the rules a load applies, never inside a caller's own transaction, and never in memory unless the +// rows committed. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from '../native-chat/agent-session-journal/journal-host-database-test-support' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { + openTestAgentSessionRecordStore, + readPersistedTestAgentSessionStore +} from './agent-session-record-store-test-harness' + +const NOW = 1_800_000_000_000 + +let root: string +let counter = 0 + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-agent-session-store-transactions-')) +}) + +afterEach(async () => { + closeTestJournalHostDatabases() + await rm(root, { recursive: true, force: true }) +}) + +/** Reserve, observe the spawn, prove the handle: a chat with a live writer. */ +async function liveChat( + store: AgentSessionRecordStore, + sessionId: string +): Promise { + counter += 1 + const reserved = await store.reserveOwner({ + sessionId, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + expectedFence: null, + spawnToken: `spawn-${counter}`, + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'client-1', + operationId: `${NOW}-${String(counter).padStart(32, '0')}`, + fingerprint: `fp-${counter}` + }, + now: NOW + }) + const fence = reserved.record.lease.runtimeFence + await store.commitProcessIdentity({ + sessionId, + fence, + process: { + hostId: 'local', + pid: 4000 + counter, + processStartTimeMs: NOW, + spawnToken: `spawn-${counter}` + }, + now: NOW + }) + return store.proveOwner({ + sessionId, + fence, + link: { + linkId: `link-${counter}`, + handle: { provider: 'claude', sessionId: `provider-${counter}`, leafUuid: null }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + }, + now: NOW + }) +} + +function totalChanges(): number { + return Number( + openTestJournalHostDatabase(root).db.prepare('SELECT total_changes() AS n').get()?.n ?? 0 + ) +} + +describe('writing a transaction', () => { + it('writes one record row for a renewal of one chat among several', async () => { + const store = await openTestAgentSessionRecordStore(root) + await liveChat(store, 'chat-a-0001') + await liveChat(store, 'chat-b-0002') + const renewed = await liveChat(store, 'chat-c-0003') + const before = totalChanges() + + await store.renewLease({ + sessionId: 'chat-c-0003', + fence: renewed.lease.runtimeFence, + childProbe: { outcome: 'identity-matched', matchedOn: ['spawn-token'] }, + now: NOW + 10_000 + }) + + expect(totalChanges() - before).toBe(1) + expect( + (await openTestAgentSessionRecordStore(root)).getRecord('chat-c-0003')?.lease.lastRenewedAt + ).toBe(NOW + 10_000) + }) + + it('writes nothing for a transaction that changes nothing', async () => { + const store = await openTestAgentSessionRecordStore(root) + await liveChat(store, 'chat-a-0001') + const before = totalChanges() + + await store.renewLeases([]) + + expect(totalChanges()).toBe(before) + }) + + // Every mutation lands after the caller's frame, so one made inside an open journal transaction + // on the same connection never nests a BEGIN there. + it('runs a store call made inside an open journal transaction after that transaction', async () => { + const store = await openTestAgentSessionRecordStore(root) + await liveChat(store, 'chat-a-0001') + let pending: Promise | null = null + + openTestJournalHostDatabase(root).transaction(() => { + pending = store.setConversationName('chat-a-0001', 'renamed') + }) + + await expect(pending).resolves.toMatchObject({ conversationName: 'renamed' }) + expect((await readPersistedTestAgentSessionStore(root)).records['chat-a-0001']).toMatchObject({ + conversationName: 'renamed' + }) + }) +}) + +describe('a change a load would refuse', () => { + it('rejects a tab id that could not prefix a pane key, and keeps memory and rows as they were', async () => { + const store = await openTestAgentSessionRecordStore(root) + await liveChat(store, 'chat-a-0001') + const persisted = await readPersistedTestAgentSessionStore(root) + + await expect( + store.setSessionTabVisibility('chat-a-0001', true, 'agent-session:chat-a-0001') + ).rejects.toThrow('agent_session_store_write_invalid') + + expect(store.getSessionTabId('chat-a-0001')).toBeNull() + expect(store.getVisibleSessionTabIndex().present).toBe(false) + expect(await readPersistedTestAgentSessionStore(root)).toEqual(persisted) + }) + + it('rejects a record whose handle chain names a link twice', async () => { + const store = await openTestAgentSessionRecordStore(root) + const before = await liveChat(store, 'chat-a-0001') + + await expect( + store.transitionHandoff('chat-a-0001', (record) => ({ + ...record, + providerHandleChain: [...record.providerHandleChain, ...record.providerHandleChain] + })) + ).rejects.toThrow('agent_session_store_write_invalid') + + expect(store.getRecord('chat-a-0001')).toBe(before) + expect( + (await openTestAgentSessionRecordStore(root)).getRecord('chat-a-0001')?.providerHandleChain + ).toHaveLength(1) + }) +}) + +describe('rows in memory', () => { + it('throws on a change made in place, which the row diff would never write', async () => { + const store = await openTestAgentSessionRecordStore(root) + await liveChat(store, 'chat-a-0001') + const loaded = (await openTestAgentSessionRecordStore(root)).getRecord('chat-a-0001') + + expect(() => Object.assign(loaded?.lease ?? {}, { runtimeFence: 99 })).toThrow(TypeError) + expect(() => + Object.assign(store.getRecord('chat-a-0001') ?? {}, { conversationName: 'x' }) + ).toThrow(TypeError) + }) +}) + +describe('after the database closes', () => { + // Late settlement at quit lands here: the caller reports it, and nothing in memory moves. + it('refuses a write with journal_closed and leaves memory as it was', async () => { + const store = await openTestAgentSessionRecordStore(root) + const before = await liveChat(store, 'chat-a-0001') + closeTestJournalHostDatabases() + + await expect(store.setConversationName('chat-a-0001', 'late')).rejects.toMatchObject({ + code: 'journal_closed' + }) + expect(store.getRecord('chat-a-0001')).toBe(before) + }) +}) diff --git a/src/main/runtime/agent-session-store-transactions.ts b/src/main/runtime/agent-session-store-transactions.ts new file mode 100644 index 000000000000..879ff4a160e8 --- /dev/null +++ b/src/main/runtime/agent-session-store-transactions.ts @@ -0,0 +1,137 @@ +// Every agent-session store mutation: draft the published state, apply, check and write exactly the +// changed rows in one journal transaction, then publish the draft. A throw anywhere discards the +// draft, so memory never holds a change the database does not. +// +// Why a promise queue although the body is synchronous: a store call must never run its BEGIN in +// the caller's frame. A caller inside an open journal transaction on the same connection would nest +// a BEGIN, which `runJournalTransaction` does not support. The queue also keeps the FIFO order and +// the async boundary every awaiting caller was written against. + +import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import { journalOpenRefusalError } from '../native-chat/agent-session-journal/journal-open-failure' +import { AgentSessionJournalError } from '../native-chat/agent-session-journal/journal-write-guards' +import type { AgentSessionStoreState } from './agent-session-record-store-file' +import { writeAgentSessionStoreRows } from './agent-session-record-rows' +import { + agentSessionStoreDraftRowWrites, + draftAgentSessionStoreState, + type AgentSessionStoreRowWrites +} from './agent-session-store-draft' + +// Why: rows are diffed by identity, so a row changed in place would never be written. Tests and +// development builds make that a TypeError; packaged builds skip the walk. +const FREEZE_ROWS = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test' + +function deepFreeze(value: unknown): void { + if (typeof value !== 'object' || value === null || Object.isFrozen(value)) { + return + } + Object.freeze(value) + for (const child of Object.values(value)) { + deepFreeze(child) + } +} + +function freezeRows( + state: AgentSessionStoreState, + writes: AgentSessionStoreRowWrites | null +): void { + if (!FREEZE_ROWS) { + return + } + const records = writes ? writes.records.upsert.map(([key]) => key) : state.records.keys() + for (const sessionId of records) { + deepFreeze(state.records.get(sessionId)) + } + const operations = writes ? writes.operations.upsert.map(([key]) => key) : state.operations.keys() + for (const key of operations) { + deepFreeze(state.operations.get(key)) + } + if (!writes || writes.retiredClaimKeys) { + state.retiredClaimKeys.forEach(deepFreeze) + } +} + +/** What every write gets on a database a newer Orca wrote: the refusal clients print as "update". */ +function readOnlyStoreRefusal(): Error { + return journalOpenRefusalError( + new AgentSessionJournalError( + 'journal_read_only', + 'the chat records were saved by a newer Orca; this host reads them and never writes' + ) + ) +} + +type StagedStoreTransaction = { + result: T + writes: AgentSessionStoreRowWrites | null + /** Publishes the draft. Only once its rows have committed. */ + adopt: () => void +} + +export class AgentSessionStoreTransactions { + private queue: Promise = Promise.resolve() + private published: AgentSessionStoreState + + constructor( + private readonly journalDatabase: JournalHostDatabase, + loaded: AgentSessionStoreState + ) { + freezeRows(loaded, null) + this.published = loaded + } + + /** The committed state. A transaction in flight never shows here until its rows have landed. */ + get state(): AgentSessionStoreState { + return this.published + } + + get readOnly(): boolean { + return this.journalDatabase.readOnly + } + + /** + * `apply` changes only the draft it is given. On a database a newer Orca wrote every transaction + * is refused, except one marked `inMemoryWhenReadOnly`: it is published without a write, for a + * verdict re-derived at every start. + */ + transact( + apply: (draft: AgentSessionStoreState) => T, + options: { inMemoryWhenReadOnly?: boolean } = {} + ): Promise { + const run = this.queue.then(() => this.commit(apply, options.inMemoryWhenReadOnly === true)) + this.queue = run.catch(() => {}) + return run + } + + private commit(apply: (draft: AgentSessionStoreState) => T, inMemoryWhenReadOnly: boolean): T { + const readOnly = this.journalDatabase.readOnly + if (readOnly && !inMemoryWhenReadOnly) { + throw readOnlyStoreRefusal() + } + const staged = this.stage(apply) + const writes = staged.writes + if (writes && !readOnly) { + this.journalDatabase.transaction((db) => writeAgentSessionStoreRows(db, writes)) + } + staged.adopt() + return staged.result + } + + private stage(apply: (draft: AgentSessionStoreState) => T): StagedStoreTransaction { + const published = this.published + const draft = draftAgentSessionStoreState(published) + const result = apply(draft) + const writes = agentSessionStoreDraftRowWrites(published, draft) + return { + result, + writes, + adopt: () => { + if (writes) { + freezeRows(draft, writes) + this.published = draft + } + } + } + } +} diff --git a/src/main/runtime/agent-session-tab-table.ts b/src/main/runtime/agent-session-tab-table.ts index 6022aac8bf2b..15ace0af7618 100644 --- a/src/main/runtime/agent-session-tab-table.ts +++ b/src/main/runtime/agent-session-tab-table.ts @@ -131,25 +131,28 @@ export function setAgentSessionTabVisibility( } } -export type PersistedAgentSessionTab = { tabId: string; sessionId: string } - -export function serializeAgentSessionTabTable(table: AgentSessionTabTable): { - sessionTabs: PersistedAgentSessionTab[] - visibleSessionIds: string[] -} { - return { - sessionTabs: table.entries().map(([tabId, sessionId]) => ({ tabId, sessionId })), - // Written for older builds, which restore tabs from this list; never read beside the table. - visibleSessionIds: table.sessionIds() +export function showAgentSessionTabs( + state: AgentSessionStoreState, + sessionIds: readonly string[] +): void { + for (const sessionId of sessionIds) { + if (state.records.has(sessionId)) { + setAgentSessionTabVisibility( + state, + sessionId, + true, + state.unrecordedSessionTabs?.tabIdFor(sessionId) + ) + } } } +export type PersistedAgentSessionTab = { tabId: string; sessionId: string } + /** - * Reads the persisted table, or seeds it from what older builds wrote: the visible session list and, - * for a chat created by a build that recorded one, the tab id on its record. That record field is - * read here and nowhere else, and only when the file carries no table. - * - * Deterministic on purpose: a parsed store must hash the same on every read of the same bytes. + * Reads the records file's table, or seeds it from what older builds wrote: the visible session list + * and, for a chat created by a build that recorded one, the tab id on its record. That record field + * is read here and nowhere else, and only when the file carries no table. */ export function parseAgentSessionTabTable( file: { sessionTabs?: unknown; visibleSessionIds?: unknown }, diff --git a/src/main/runtime/agent-session-unreadable-record-salvage.test.ts b/src/main/runtime/agent-session-unreadable-record-salvage.test.ts deleted file mode 100644 index cde4091d7ebf..000000000000 --- a/src/main/runtime/agent-session-unreadable-record-salvage.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -// Unreadable session records: quarantine when nothing can vouch for the session, salvage -// when the previous committed state can. - -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import { AgentSessionRecordStore } from './agent-session-record-store' -import { agentSessionStorePath } from './agent-session-record-store-file' -import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' - -const NOW = 1_800_000_000_000 -let directory: string -let counter = 0 - -function operationId(): string { - counter += 1 - return `${NOW}-${String(counter) - .padStart(32, '0') - .replaceAll(/[^0-9a-f]/g, '0')}` -} - -function reserveRequest( - overrides: Partial = {} -): AgentSessionReserveRequest { - return { - sessionId: 'session-alpha', - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' - }, - provider: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, - expectedFence: null, - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'indeterminate', reason: 'no answer' }, - operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-1' }, - now: NOW, - ...overrides - } -} - -async function open(): Promise { - return AgentSessionRecordStore.open({ directory, hostId: 'local' }) -} - -/** Reserve, observe the spawn, prove the handle — the full path to an admitted writer. */ -async function establishOwner(store: AgentSessionRecordStore): Promise { - const reserved = await store.reserveOwner(reserveRequest()) - const fence = reserved.record.lease.runtimeFence - await store.commitProcessIdentity({ - sessionId: 'session-alpha', - fence, - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: reserved.record.lease.reservedSpawnToken ?? 'spawn-a' - }, - now: NOW - }) - return store.proveOwner({ - sessionId: 'session-alpha', - fence, - link: { - linkId: 'link-1', - handle: { provider: 'claude', sessionId: 'provider-session-1', leafUuid: 'leaf-1' }, - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - }, - now: NOW - }) -} - -async function corruptPrimaryRecord(): Promise { - const filePath = agentSessionStorePath(directory) - const raw = JSON.parse(await readFile(filePath, 'utf-8')) - raw.records['session-alpha'].lease.runtimeFence = 'not-a-number' - await writeFile(filePath, JSON.stringify(raw)) - return filePath -} - -beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-salvage-')) -}) - -afterEach(async () => { - await rm(directory, { recursive: true, force: true }) -}) - -describe('unreadable session records', () => { - it('quarantines an unreadable record with no committed copy and refuses to own it', async () => { - const first = await open() - await establishOwner(first) - const filePath = await corruptPrimaryRecord() - // No previous committed state survives, so nothing can vouch for the session. - await rm(`${filePath}.bak`, { force: true }) - - const reopened = await open() - expect(reopened.getRecord('session-alpha')).toBeNull() - expect(reopened.isSessionUnreadable('session-alpha')).toBe(true) - await expect(reopened.reserveOwner(reserveRequest())).rejects.toThrow( - 'execution_owner_reconciling' - ) - // The row stays verbatim inside an explicit unusable envelope. - await reopened.retireClaimKey('key-2', NOW) - const persisted = JSON.parse(await readFile(filePath, 'utf-8')) - expect(persisted.records).not.toHaveProperty('session-alpha') - expect(persisted.unusableRecords['session-alpha']).toMatchObject({ - reason: 'current_shape_invalid', - raw: { lease: { runtimeFence: 'not-a-number' } } - }) - }) - - it('salvages the last committed copy of a record the primary retains as unreadable', async () => { - const first = await open() - await establishOwner(first) - // One more commit leaves the proven-owner record in the backup file. - await first.setJournalCheckpoint({ - sessionId: 'session-alpha', - fence: 1, - checkpoint: { epoch: 1, sequence: 1 }, - now: NOW - }) - const filePath = await corruptPrimaryRecord() - - const reopened = await open() - // The previous committed state vouches for the session; its lease is re-adjudicated - // like any other, and the unreadable bytes stay quarantined verbatim. - expect(reopened.getRecord('session-alpha')?.lease).toMatchObject({ - runtimeFence: 1, - claimStatus: 'live' - }) - expect(reopened.recoveredFromBackup).toBe(false) - expect(reopened.isSessionUnreadable('session-alpha')).toBe(true) - await reopened.reconcileOnRestart({ - probe: async () => ({ outcome: 'pid-absent' }), - now: NOW + 1 - }) - expect(reopened.getRecord('session-alpha')?.lease.claimStatus).toBe('released') - - // Ownership is reachable again instead of refused with execution_owner_reconciling. - const reserved = await reopened.reserveOwner( - reserveRequest({ expectedFence: 2, spawnToken: 'spawn-b' }) - ) - expect(reserved.record.lease.claimStatus).toBe('reserved') - const persisted = JSON.parse(await readFile(filePath, 'utf-8')) - expect(persisted.records['session-alpha'].lease.claimStatus).toBe('reserved') - expect(persisted.unusableRecords['session-alpha']).toMatchObject({ - reason: 'current_shape_invalid' - }) - - // Salvage only fills gaps: with the live record readable again, a reload must never - // let the stale backup copy clobber it. - const reloaded = await open() - expect(reloaded.getRecord('session-alpha')?.lease).toMatchObject({ - claimStatus: 'reserved', - runtimeFence: 3 - }) - }) -}) diff --git a/src/main/runtime/agent-transcript-replay-test-harness.ts b/src/main/runtime/agent-transcript-replay-test-harness.ts index 5b57656ab4ac..5e08b4ad01f8 100644 --- a/src/main/runtime/agent-transcript-replay-test-harness.ts +++ b/src/main/runtime/agent-transcript-replay-test-harness.ts @@ -7,20 +7,30 @@ import { normalizeTerminalChunk } from './terminal-ansi-normalization' import { appendNormalizedToTailBuffer } from './terminal-tail-buffer' import { buildPreview } from './terminal-tail-state' import { buildTerminalWaitText } from './terminal-wait-tail-state' +import { visibleNonBlankTerminalLines } from './terminal-tail-read' const DEFAULT_CHUNK_CHARS = 64 -export type TranscriptReplayFrame = { screenLines: string[]; waitText: string } +/** `screenLines` are readLiveTerminalScreenLines' rows; `ruledScreenLines` readScreenRuledLines'. */ +export type TranscriptReplayFrame = { + screenLines: string[] + ruledScreenLines: string[] + waitText: string +} export function readRuntimeFixture(name: string): string { return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8') } +/** Resizes the grid before chunk `atChunk`, as a PTY resize landing mid-paint would. */ +export type TranscriptReplayResize = { atChunk: number; cols: number; rows: number } + /** A string is cut into fixed 64-char chunks; an array replays the recorded PTY chunks as-is. */ export async function* replayTranscript( data: string | readonly string[], cols: number, - rows: number + rows: number, + resize?: TranscriptReplayResize ): AsyncGenerator { const chunks = typeof data === 'string' ? splitIntoChunks(data) : data const emulator = new HeadlessEmulator({ cols, rows }) @@ -29,7 +39,10 @@ export async function* replayTranscript( let pendingAnsi = '' let redrawCursor: ReturnType['redrawCursor'] = null try { - for (const chunk of chunks) { + for (const [index, chunk] of chunks.entries()) { + if (index === resize?.atChunk) { + emulator.resize(resize.cols, resize.rows) + } await emulator.write(chunk) const normalized = normalizeTerminalChunk(chunk, pendingAnsi) pendingAnsi = normalized.pendingAnsi @@ -39,6 +52,7 @@ export async function* replayTranscript( redrawCursor = tail.redrawCursor yield { screenLines: projectTerminalVisibleLines(emulator).lines, + ruledScreenLines: visibleNonBlankTerminalLines(emulator.getVisibleLines()), waitText: buildTerminalWaitText(lines, partialLine, buildPreview(lines, partialLine)) } } @@ -47,6 +61,39 @@ export async function* replayTranscript( } } +export async function finalReplayFrame( + name: string, + cols: number, + rows: number, + resize?: TranscriptReplayResize +): Promise { + let last: TranscriptReplayFrame | null = null + for await (const frame of replayTranscript(readRuntimeFixture(name), cols, rows, resize)) { + last = frame + } + if (!last) { + throw new Error(`empty fixture ${name}`) + } + return last +} + +/** The final screen as `terminal read --screen` projects it, composer draft blanked. */ +export async function finalReadProjection( + name: string, + cols: number, + rows: number +): Promise<{ lines: string[]; draft?: string }> { + const emulator = new HeadlessEmulator({ cols, rows }) + try { + for (const chunk of splitIntoChunks(readRuntimeFixture(name))) { + await emulator.write(chunk) + } + return projectTerminalVisibleLines(emulator) + } finally { + emulator.dispose() + } +} + function splitIntoChunks(data: string): string[] { const chunks: string[] = [] for (let offset = 0; offset < data.length; offset += DEFAULT_CHUNK_CHARS) { diff --git a/src/main/runtime/antigravity-screen-readiness-transcripts.test.ts b/src/main/runtime/antigravity-screen-readiness-transcripts.test.ts new file mode 100644 index 000000000000..14746104ffd6 --- /dev/null +++ b/src/main/runtime/antigravity-screen-readiness-transcripts.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it, vi } from 'vitest' +import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' +import { + finalReplayFrame, + readRuntimeFixture, + replayTranscript +} from './agent-transcript-replay-test-harness' +import { isAntigravityComposerReadyScreen } from './antigravity-terminal-readiness' +import { describeScreenRuledAgentTranscripts } from './screen-ruled-agent-transcript-suite' +import { + isKnownReadyPromptBody, + isKnownReadyPromptPreview, + isQuietReadyScreenBody +} from './terminal-wait-detection' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +// agy 1.2.14 on macOS, AGY_CLI_HIDE_ACCOUNT_INFO=1 (see each .meta.json); STA-8741. +const at120x40 = (name: string, what: string) => ({ name, what, cols: 120, rows: 40 }) +const READY = [ + at120x40('antigravity-1-2-14-ready', 'settled startup'), + at120x40('antigravity-1-2-14-ready-accept-edits', '--mode accept-edits'), + at120x40('antigravity-1-2-14-ready-plan', '--mode plan'), + { name: 'antigravity-1-2-14-ready-80x24', what: 'settled startup', cols: 80, rows: 24 }, + at120x40('antigravity-1-2-14-picker-dismissed', '/model closed with Esc'), + at120x40('antigravity-1-2-14-turn-ended', 'a turn has ended') +] +const NOT_READY = [ + at120x40('antigravity-1-2-14-model-picker', '/model picker open'), + at120x40('antigravity-1-2-14-command-palette', 'slash palette open'), + at120x40('antigravity-1-2-14-busy-thinking', 'spinner before the answer'), + at120x40('antigravity-1-2-14-busy-streaming', 'answer streaming'), + at120x40('antigravity-1-2-14-trust-dialog', 'workspace trust dialog'), + at120x40('antigravity-1-2-14-draft', 'unsent text in the composer') +] + +describe('Antigravity 1.2.14 readiness from captured bytes', () => { + describeScreenRuledAgentTranscripts({ + agent: 'antigravity', + foregroundProcess: 'agy', + rule: isAntigravityComposerReadyScreen, + ready: READY, + notReady: NOT_READY, + // Why these: the line-folded text rule reads only these ready screens. + readyWithoutScreen: [ + 'antigravity-1-2-14-ready', + 'antigravity-1-2-14-ready-80x24', + 'antigravity-1-2-14-picker-dismissed' + ] + }) + + it.each([ + 'antigravity-1-2-14-ready-accept-edits', + 'antigravity-1-2-14-ready-plan', + 'antigravity-1-2-14-turn-ended' + ])('%s: the line-folded text rule alone misses this ready screen', async (name) => { + const { waitText } = await finalReplayFrame(name, 120, 40) + expect(isKnownReadyPromptPreview(waitText)).toBe(false) + }) + + it('does not let the text rules overrule a screen that refused', async () => { + const { waitText } = await finalReplayFrame('antigravity-1-2-14-picker-dismissed', 120, 40) + const { ruledScreenLines } = await finalReplayFrame('antigravity-1-2-14-model-picker', 120, 40) + // Presence precondition: the text alone would say ready. + expect(isKnownReadyPromptPreview(waitText)).toBe(true) + expect(isQuietReadyScreenBody(waitText, 'antigravity', () => ruledScreenLines)).toBe(false) + expect(isKnownReadyPromptBody(waitText, 'antigravity', () => ruledScreenLines, false)).toBe( + false + ) + }) + + // Why a caret rule is not enough: agy keeps the bare composer caret painted through both. + it.each(['antigravity-1-2-14-busy-streaming', 'antigravity-1-2-14-model-picker'])( + '%s: the bare caret is still on screen', + async (name) => { + const { ruledScreenLines } = await finalReplayFrame(name, 120, 40) + expect(ruledScreenLines.some((line) => line.trim() === '>')).toBe(true) + } + ) + + // Why a clocked pane waits for quiet: the submit repaint clears the composer a moment before + // it swaps `? for shortcuts` for `esc to cancel`. + it('reads a submit repaint as ready for a moment mid-turn', async () => { + const data = readRuntimeFixture('antigravity-1-2-14-turn-ended') + let submitted = false + let answered = false + let readyMidTurn = 0 + for await (const { ruledScreenLines } of replayTranscript(data, 120, 40)) { + submitted ||= ruledScreenLines.some((line) => line.startsWith('> Without using any tools')) + answered ||= ruledScreenLines.some((line) => line.trim() === 'ok') + if (submitted && !answered && isAntigravityComposerReadyScreen(ruledScreenLines)) { + readyMidTurn += 1 + } + } + expect(submitted && answered).toBe(true) + expect(readyMidTurn).toBeGreaterThan(0) + }) + + // Why: a shell auto-title names the process; before the screen decided, that name-only idle + // title settled a pane whose picker carries no blocked wording. + it('does not settle an open model picker from a name-only `agy` title', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'agy', + foregroundProcess: 'agy', + launchAgent: 'antigravity', + data: `${String.fromCharCode(27)}]0;agy${String.fromCharCode(7)}${readRuntimeFixture('antigravity-1-2-14-model-picker')}`, + size: { cols: 120, rows: 40 } + }) + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) + ).rejects.toThrow(/timeout/) + }, 15_000) + + // Why this recording: only the screen reads it ready, so settling proves the grid is trusted. + it('trusts a reflowed grid again once a PTY resize off it repaints the TUI', async () => { + const turnEnded = readRuntimeFixture('antigravity-1-2-14-turn-ended') + const options = { + paneTitle: 'Terminal', + foregroundProcess: 'agy', + launchAgent: 'antigravity' as const, + data: turnEnded, + size: { cols: 100, rows: 30 } + } + const { runtime, handle } = await createTranscriptPane(options) + const resizePty = (cols: number, rows: number) => { + options.size = { cols, rows } + runtime.onExternalPtyResize(TRANSCRIPT_PANE_PTY_ID, cols, rows) + } + const settles = async () => + ( + await runtime + .waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) + .catch(() => ({ satisfied: false })) + ).satisfied + options.size = { cols: 120, rows: 40 } + runtime.reflowHeadlessTerminalToPtyGrid(TRANSCRIPT_PANE_PTY_ID, 120, 40) + await runtime.readTerminal(handle, { screen: true }) + expect(await settles()).toBe(false) + resizePty(121, 40) + resizePty(120, 40) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, turnEnded, Date.now()) + await runtime.readTerminal(handle, { screen: true }) + expect(await settles()).toBe(true) + }, 45_000) +}) diff --git a/src/main/runtime/antigravity-terminal-readiness.ts b/src/main/runtime/antigravity-terminal-readiness.ts index 3e624434f89e..81e42109f455 100644 --- a/src/main/runtime/antigravity-terminal-readiness.ts +++ b/src/main/runtime/antigravity-terminal-readiness.ts @@ -5,12 +5,29 @@ import { isTerminalWaitWhitespace } from './terminal-wait-tail-window' * line anchors. The idle composer is the only captured marker that survives every ready screen. */ export function findAntigravityReadyPromptIndex(normalized: string): number | null { - return findAntigravityComposerIndex(normalized, true) + return findAntigravityComposerIndex(normalized) } -/** Visible-screen snapshots may omit the banner after a dialog closes. */ -export function isAntigravityReadyPromptSnapshot(text: string): boolean { - return findAntigravityComposerIndex(text.toLowerCase(), false) !== null +const SCREEN_RULE_RE = /^─{8,}$/ + +/** + * The live screen's bottom four rows at an idle composer: rule, caret, rule, `? for shortcuts`. + * Why the hint row decides: the caret stays painted through a turn and behind the `/model` + * picker, but the hint reads `esc to cancel` mid-turn and in the palette, and the picker covers it. + */ +export function isAntigravityComposerReadyScreen(screenLines: readonly string[]): boolean { + if (screenLines.length < 4) { + return false + } + const [top = '', composer = '', bottom = '', hint = ''] = screenLines + .slice(-4) + .map((line) => line.trim()) + return ( + SCREEN_RULE_RE.test(top) && + isComposerLine(composer) && + SCREEN_RULE_RE.test(bottom) && + hint.toLowerCase().startsWith('? for shortcuts') + ) } /** @@ -50,10 +67,9 @@ function isModelRow(line: string): boolean { return true } -function findAntigravityComposerIndex(normalized: string, requireHeader: boolean): number | null { - const headerIndex = normalized.lastIndexOf('antigravity cli') - const contentStart = headerIndex === -1 ? 0 : headerIndex - if (requireHeader && headerIndex === -1) { +function findAntigravityComposerIndex(normalized: string): number | null { + const contentStart = normalized.lastIndexOf('antigravity cli') + if (contentStart === -1) { return null } diff --git a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts index becdc9f2b389..9fa9cbfcfa6a 100644 --- a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts +++ b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts @@ -1,12 +1,17 @@ // A Claude send whose write ended in doubt is recorded `unknown`, and a live `unknown` reads as -// work still owed. The CLI reports `session_state_changed idle` only once its queue has drained, -// so that report retires the doubt; a send whose dispatch is still `pending` is left alone. +// work still owed. The CLI reporting `session_state_changed idle` retires the doubt; a send whose +// dispatch is still `pending` is left alone unless the CLI had started it. // Against the production runtime, adapter, record store and host, with only the CLI scripted. import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' -import { DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' +import { + DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED, + DISPATCH_DOUBT_PROVIDER_IDLE, + DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN +} from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' +import { hasUnansweredStructuredAgentSessionDispatch } from '../../shared/structured-agent-session-unanswered-dispatch' import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' @@ -106,3 +111,81 @@ describe('a live Claude chat whose CLI reports idle', () => { expect((await submission(host, pending))?.recovered).toBeUndefined() }) }) + +function lifecycle(commandUuid: string, state: string): Record { + return { + type: 'command_lifecycle', + command_uuid: commandUuid, + state, + uuid: `lifecycle-${state}`, + session_id: PROVIDER_SESSION + } +} + +describe('a live Claude chat whose CLI took a send and let it go without an echo', () => { + it.each([ + ['its turn ended cancelled', 'cancelled', DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED], + ['it went idle with no terminal state', 'idle', DISPATCH_DOUBT_PROVIDER_IDLE] + ])('records released doubt when %s', async (_label, ending, reason) => { + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + const child = claude.child(SESSION) + let sentUuid = '' + child.connection.send = async (message, beforeDispatch) => { + await beforeDispatch?.() + sentUuid = String(message.uuid) + } + const taken = await send(host, 'retrying') + // The pending row lands before the adapter's write; the write is what names the uuid. + await vi.waitFor(() => expect(sentUuid).not.toBe('')) + expect(await submission(host, taken)).toMatchObject({ dispatchState: 'pending' }) + child.handlers.onMessage?.(lifecycle(sentUuid, 'queued')) + child.handlers.onMessage?.(lifecycle(sentUuid, 'started')) + expect(host.deps.adapter.holdsDispatch?.(SESSION)).toBe(true) + + child.handlers.onMessage?.( + ending === 'idle' ? sessionState('idle') : lifecycle(sentUuid, ending) + ) + + await vi.waitFor(async () => + expect(await submission(host, taken)).toMatchObject({ + dispatchState: 'unknown', + recovered: true, + reason + }) + ) + expect(host.deps.adapter.holdsDispatch?.(SESSION)).toBe(false) + // Released doubt: the chat stops reading working, and nothing re-sends it. + expect( + hasUnansweredStructuredAgentSessionDispatch((await host.journalSnapshot(SESSION)).submissions) + ).toBe(false) + }) + + it('holds a send it has only queued, through an idle that may precede its start', async () => { + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + const child = claude.child(SESSION) + let sentUuid = '' + child.connection.send = async (message, beforeDispatch) => { + await beforeDispatch?.() + sentUuid = String(message.uuid) + } + const queued = await send(host, 'behind a turn') + await vi.waitFor(() => expect(sentUuid).not.toBe('')) + // POSITIVE CONTROL: written but not yet taken, nothing holds the child. + expect(host.deps.adapter.holdsDispatch?.(SESSION)).toBe(false) + + child.handlers.onMessage?.(lifecycle(sentUuid, 'queued')) + expect(host.deps.adapter.holdsDispatch?.(SESSION)).toBe(true) + child.handlers.onMessage?.(sessionState('idle')) + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(host.deps.adapter.holdsDispatch?.(SESSION)).toBe(true) + expect(await submission(host, queued)).toMatchObject({ dispatchState: 'pending' }) + expect((await submission(host, queued))?.recovered).toBeUndefined() + }) +}) diff --git a/src/main/runtime/claude-structured-resumed-start-failure.test.ts b/src/main/runtime/claude-structured-resumed-start-failure.test.ts index 7a0c66e1b85c..9d274cd68a8c 100644 --- a/src/main/runtime/claude-structured-resumed-start-failure.test.ts +++ b/src/main/runtime/claude-structured-resumed-start-failure.test.ts @@ -40,7 +40,7 @@ describe('a reopened Claude chat whose CLI dies before initialize', () => { ok: true }) await waitForStructuredAgentSessionRecovery() - await host.close(SESSION) + await host.close(SESSION, 'evict') // The user reopens it and sends; this time the CLI never answers, then dies, and its tree is // unprovable. Opening starts nothing: the send does. diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 82db5d6c5425..47199f6199f5 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -15,6 +15,7 @@ import { CLAUDE_SPAWN_TOKEN_ENV } from '../claude/claude-structured-owner-identi import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredAgentSessionStatusSink } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' +import { AgentHookServer, _internals } from '../agent-hooks/server' import type { OrcaRuntimeService } from './orca-runtime' import type { RpcRequest, RpcResponse } from './rpc/core' import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' @@ -138,6 +139,7 @@ let shellEnv: NodeJS.ProcessEnv let shellEnvironmentPolicy: NativeChatShellEnvironmentPolicy /** What the host handed its status sink as child work. */ let childWork: Parameters>[] +let hookServer: AgentHookServer async function call(method: string, params: unknown): Promise { const replies: RpcResponse[] = [] @@ -218,6 +220,8 @@ beforeEach(async () => { claude = fakeClaude(PROVIDER_SESSION) cleanups = new Map() childWork = [] + _internals.resetCachesForTests() + hookServer = new AgentHookServer() const runtime = { getRuntimeId: () => 'runtime-1', getClientSettings: () => ({ experimentalStructuredNativeChat: true }), @@ -243,10 +247,15 @@ beforeEach(async () => { resolveShellEnvironmentPolicy: () => shellEnvironmentPolicy, resolveClaudeAuthPolicy: () => claudeAuthPolicy, openClaudeConnection: claude.openConnection, + // Production's sink wiring onto a real hook server, whose records a Stop reaches. statusSink: { - publish: () => {}, - forget: () => {}, - publishChildWork: (...args) => childWork.push(args) + publish: (summary, subject) => hookServer.ingestStructuredStatus(summary, subject), + forget: (subject) => hookServer.dropStructuredStatus(subject), + publishChildWork: (...args) => { + childWork.push(args) + hookServer.ingestStructuredChildWork(...args) + }, + readChildWork: (subject) => hookServer.getStructuredChildWorkViews(subject) } }).then(() => undefined), registerSubscriptionCleanup: (id: string, dispose: () => void) => cleanups.set(id, dispose), @@ -504,7 +513,7 @@ describe('a structured Claude session over agentSession.*', () => { } const host = getStructuredAgentSessionHost() // The lease follows the root, so the host lets go. - await host?.close(SESSION) + await host?.close(SESSION, 'evict') expect(host?.hasSession(SESSION)).toBe(false) // The user comes back and sends: that send is what starts Claude again. @@ -656,16 +665,22 @@ describe('a structured Claude session over agentSession.*', () => { }) await getStructuredAgentSessionHost()?.flushStreamedEvents(SESSION) - claude.live().handlers.onMessage?.({ - type: 'system', - subtype: 'background_tasks_changed', - session_id: PROVIDER_SESSION, - uuid: 'background-roster', - tasks: [ - { task_id: 'task-one', task_type: 'local_agent', description: 'First task' }, - { task_id: 'task-two', task_type: 'local_bash', description: 'Second task' } - ] - }) + for (const [taskId, taskType, description] of [ + ['task-one', 'local_agent', 'First task'], + ['task-two', 'local_bash', 'Second task'] + ]) { + claude.live().handlers.onMessage?.({ + type: 'system', + subtype: 'task_started', + session_id: PROVIDER_SESSION, + uuid: `started-${taskId}`, + task_id: taskId, + task_type: taskType, + description, + is_backgrounded: true + }) + } + await getStructuredAgentSessionHost()?.flushStreamedEvents(SESSION) const itemsBeforeTaskStop = itemsOf(stream) const targetedStopFields = { turnId: 'background-tasks', @@ -682,6 +697,16 @@ describe('a structured Claude session over agentSession.*', () => { { subtype: 'stop_task', params: { taskId: 'task-two' } } ]) expect(itemsOf(stream)).toEqual(itemsBeforeTaskStop) + // The acknowledged stop ends the record it reached, and only that one. + const subject = childWork.at(-1)![0] + expect( + hookServer + .getStructuredChildWorkViews(subject) + .map((view) => [view.providerId, view.membership, view.outcome]) + ).toEqual([ + ['task-one', 'live', undefined], + ['task-two', 'settled', 'cancelled'] + ]) const staleStopFields = { turnId: 'background-tasks', @@ -755,15 +780,35 @@ describe('a structured Claude session over agentSession.*', () => { provider: 'claude', leafUuid: 'assistant-leaf' }) + // A Claude Stop ends its child once Claude ends the stopped turn, so the chat rests; the next + // open resumes the conversation. const old = claude.live() - const resumed = await ok<{ fence: number }>('agentSession.ensure', ensureParams(created.fence)) - expect(resumed.fence).toBe(created.fence + 1) + old.handlers.onMessage?.({ + type: 'result', + subtype: 'error_during_execution', + is_error: true, + session_id: PROVIDER_SESSION, + uuid: 'interrupted-result' + }) + await vi.waitFor(() => expect(leaseOf(SESSION).claimStatus).toBe('released')) expect(old.closed).toBe(true) - // Claude owns where the conversation continues; the stored leaf is the last completed turn. + const rested = leaseOf(SESSION) + const resumed = await ok<{ fence: number }>( + 'agentSession.ensure', + ensureParams(rested.runtimeFence) + ) + expect(resumed.fence).toBe(rested.runtimeFence + 1) + expect(claude.live()).not.toBe(old) + // Claude owns where the conversation continues; the stored leaf is the stopped turn's, which + // Claude ended with its own result. expect(claude.live().launch.options).toMatchObject({ resume: PROVIDER_SESSION }) expect(claude.live().launch.options).not.toHaveProperty('resumeSessionAt') const lastCompletedTurn = { - handle: { provider: 'claude', sessionId: PROVIDER_SESSION, leafUuid: 'assistant-leaf' }, + handle: { + provider: 'claude', + sessionId: PROVIDER_SESSION, + leafUuid: 'provider-opened-assistant' + }, origin: 'resumed' } expect(host.deps.store.getRecord(SESSION).providerHandleChain.at(-1)).toMatchObject( diff --git a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts index ce26647ae85f..56ba00618836 100644 --- a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts +++ b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts @@ -6,7 +6,6 @@ // the saved choice stays saved for the next start to retry. Against // the production runtime, adapter, record store and host, with only the CLI process scripted. -import { createHash } from 'node:crypto' import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' @@ -79,16 +78,24 @@ async function statusRows(host: StructuredAgentSessionHost): Promise { } async function send(host: StructuredAgentSessionHost, text: string): Promise { + await sendTo(host, SESSION, text) +} + +async function sendTo( + host: StructuredAgentSessionHost, + sessionId: string, + text: string +): Promise { const body = hostTestMessage(text) await expect( host.send(CALLER, { envelope: { - sessionId: SESSION, - clientOperationId: `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}`, - expectedRuntimeFence: record(host)?.lease.runtimeFence ?? 0, + sessionId, + clientOperationId: operationId(), + expectedRuntimeFence: host.deps.store.getRecord(sessionId)?.lease.runtimeFence ?? 0, payloadFingerprint: computeAgentSessionPayloadFingerprint({ method: 'agentSession.send', - sessionId: SESSION, + sessionId, fields: { body } }) }, @@ -149,12 +156,8 @@ describe('a Claude start whose CLI answers initialize but not a control request' }) it('replays the unanswered saved model after a turn reports another model, another option changes, and the chat is cleared', async () => { - const clearOperation = operationId() - const replacement = `clear-${createHash('sha256') - .update(JSON.stringify([SESSION, CALLER.callerKey, clearOperation])) - .digest('hex') - .slice(0, 40)}` - claude = createScriptedClaudeRuntime([SESSION, replacement]) + const sessions = [SESSION] + claude = createScriptedClaudeRuntime(sessions) const behavior = { optionWritesHang: true, controlTimeoutMs: DEADLINE_MS } claude.behave(SESSION, behavior) vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -187,7 +190,7 @@ describe('a Claude start whose CLI answers initialize but not a control request' command: 'clear', envelope: { sessionId: SESSION, - clientOperationId: clearOperation, + clientOperationId: operationId(), expectedRuntimeFence: record(host)?.lease.runtimeFence ?? 0, payloadFingerprint: computeAgentSessionPayloadFingerprint({ method: 'agentSession.conversationCommand', @@ -198,10 +201,20 @@ describe('a Claude start whose CLI answers initialize but not a control request' }) expect(cleared, JSON.stringify(cleared)).toMatchObject({ ok: true, - value: { replacementSessionId: replacement } + value: { replacementSessionId: expect.any(String) } }) - // The cleared chat's start replays the saved model rather than the one the turn reported. + const replacement = cleared.ok ? cleared.value.replacementSessionId! : '' + sessions.push(replacement) + // The cleared chat starts nothing until its first message. + expect(claude.children(replacement)).toEqual([]) + await sendTo(host, replacement, 'first message') + // Started fresh under its own derived id, and it replays the saved model rather than the one + // the turn reported. await vi.waitFor(() => expect(claude.child(replacement).calls).toContain('set_model')) + expect(claude.child(replacement).launch.options).toMatchObject({ + sessionId: claudeSessionIdForOrcaSession(replacement) + }) + expect(claude.child(replacement).launch.options.resume).toBeUndefined() await vi.waitFor(() => expect(host.deps.store.getRecord(replacement)?.options).toEqual({ model: 'sonnet', diff --git a/src/main/runtime/cline-screen-readiness-transcripts.test.ts b/src/main/runtime/cline-screen-readiness-transcripts.test.ts new file mode 100644 index 000000000000..e5c51be5d5c9 --- /dev/null +++ b/src/main/runtime/cline-screen-readiness-transcripts.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from 'vitest' +import { createTranscriptPane } from './agent-transcript-pane-test-harness' +import { + finalReadProjection, + finalReplayFrame, + readRuntimeFixture, + replayTranscript +} from './agent-transcript-replay-test-harness' +import { isClineComposerReadyScreen } from './cline-terminal-readiness' +import { describeScreenRuledAgentTranscripts } from './screen-ruled-agent-transcript-suite' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +// cline 3.0.66 on macOS with an isolated config; 3.0.65 on Windows recorded for PR #23269 (see +// each .meta.json); STA-8741. +const at120x40 = (name: string, what: string) => ({ name, what, cols: 120, rows: 40 }) +const READY = [ + at120x40('cline-3-0-66-ready', 'promo dismissed, startup composer'), + at120x40('cline-3-0-66-ready-plan', 'Plan mode composer'), + { name: 'cline-3-0-66-ready-80x24', what: 'startup composer', cols: 80, rows: 24 }, + at120x40('cline-3-0-66-turn-ended', 'a turn has ended'), + at120x40('cline-3-0-65-win32-startup', 'Windows startup composer') +] +const NOT_READY = [ + at120x40('cline-3-0-66-promo', 'Cline Desktop promo over the composer'), + at120x40('cline-3-0-66-permission', 'tool approval prompt'), + at120x40('cline-3-0-66-slash-menu', 'slash menu open'), + at120x40('cline-3-0-66-draft', 'unsent text in the composer') +] +const STREAMING = 'cline-3-0-66-busy-streaming' + +describe('Cline readiness from captured bytes', () => { + describeScreenRuledAgentTranscripts({ + agent: 'cline', + foregroundProcess: 'cline', + rule: isClineComposerReadyScreen, + ready: READY, + notReady: NOT_READY, + // Why all: an idle Cline is quiet, so the quiet-process lane settles it. + readyWithoutScreen: READY.map(({ name }) => name) + }) + + // Presence precondition for the restored-pane suite: the read projection blanks this placeholder. + it('reads the ended turn through a projection that blanks its placeholder', async () => { + const { lines, draft } = await finalReadProjection('cline-3-0-66-turn-ended', 120, 40) + expect(lines).toContain('❯') + expect(draft).toBe('Ask anything...') + }) + + // Why only quiescence can refuse it: the streaming reply has scrolled its spinner away. + it('paints the same empty composer while a reply streams', async () => { + const { ruledScreenLines } = await finalReplayFrame(STREAMING, 120, 40) + expect(isClineComposerReadyScreen(ruledScreenLines)).toBe(true) + }) + + it('refuses every frame whose spinner row is still on screen', async () => { + let spinnerFrames = 0 + for await (const { ruledScreenLines } of replayTranscript( + readRuntimeFixture(STREAMING), + 120, + 40 + )) { + if (ruledScreenLines.some((line) => /[\u2800-\u28ff] Thinking/.test(line))) { + spinnerFrames += 1 + expect(isClineComposerReadyScreen(ruledScreenLines)).toBe(false) + } + } + // Presence precondition: the thinking spinner was painted above the composer. + expect(spinnerFrames).toBeGreaterThan(0) + }) + + it('does not settle a streaming pane before it goes quiet', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'cline', + launchAgent: 'cline', + data: readRuntimeFixture(STREAMING), + size: { cols: 120, rows: 40 } + }) + // Why 2.5s: inside the 3s quiescence window, past the 2s poll. + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_500 }) + ).rejects.toThrow(/timeout/) + }, 15_000) +}) diff --git a/src/main/runtime/cline-terminal-readiness.ts b/src/main/runtime/cline-terminal-readiness.ts new file mode 100644 index 000000000000..4088eea4ab1e --- /dev/null +++ b/src/main/runtime/cline-terminal-readiness.ts @@ -0,0 +1,29 @@ +const CLINE_RULE_RE = /^─{8,}$/ +// Captured placeholders: startup (Act), after a turn (Act), and Plan mode. +const CLINE_EMPTY_COMPOSERS: ReadonlySet = new Set([ + '❯ what can i do for you?', + '❯ ask anything...', + '❯ plan something...' +]) +const CLINE_MODE_ROW_RE = /[○●] plan [○●] act \(tab\)$/ +const BRAILLE_SPINNER_RE = /[⠀-⣿]/ + +/** + * Cline 3.0.x's empty composer box with its Plan/Act status rows under it. + * Why it proves only that the composer is up: Cline repaints the same box while a reply streams + * and its spinner row has scrolled away, so callers must also require a quiet stream. + */ +export function isClineComposerReadyScreen(screenLines: readonly string[]): boolean { + const lines = screenLines.map((line) => line.trim().toLowerCase()) + const modeRow = lines.findLastIndex((line) => CLINE_MODE_ROW_RE.test(line)) + // Why bounded: the mode row sits above the cwd and auto-approve rows, never higher. + if (modeRow < 3 || modeRow < lines.length - 3) { + return false + } + return ( + CLINE_RULE_RE.test(lines[modeRow - 1]) && + CLINE_EMPTY_COMPOSERS.has(lines[modeRow - 2]) && + CLINE_RULE_RE.test(lines[modeRow - 3]) && + !lines.slice(0, modeRow - 3).some((line) => BRAILLE_SPINNER_RE.test(line)) + ) +} diff --git a/src/main/runtime/codex-quiet-ready-screen.test.ts b/src/main/runtime/codex-quiet-ready-screen.test.ts index 223eef2d858e..97f63f0db27b 100644 --- a/src/main/runtime/codex-quiet-ready-screen.test.ts +++ b/src/main/runtime/codex-quiet-ready-screen.test.ts @@ -197,6 +197,7 @@ describe('Codex composer ready screen, frame by frame', () => { readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText, 'codex', () => screenLines), agent: 'codex', + readScreenDecidesReadiness: () => false, firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS }) @@ -293,6 +294,7 @@ describe('a busy 0.150-0.157 pane whose header stays in the tail', () => { readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText, 'codex', () => screenLines), agent: 'codex', + readScreenDecidesReadiness: () => false, firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS }) @@ -349,6 +351,7 @@ describe('a busy 0.150-0.157 pane whose header stays in the tail', () => { isKnownReadyPromptBody(waitText, 'codex', () => header, record.lastOutputAt !== null), readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText, 'codex', () => header), agent: 'codex', + readScreenDecidesReadiness: () => false, firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS }) @@ -377,6 +380,7 @@ describe('reading the live screen never removes quiet-lane readiness', () => { readPositiveBodyEvidence: () => isKnownReadyPromptBody(frame.waitText, agent, () => frame.screenLines, true), agent, + readScreenDecidesReadiness: () => false, firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS } satisfies Omit diff --git a/src/main/runtime/cursor-approval-prompt-detection.ts b/src/main/runtime/cursor-approval-prompt-detection.ts new file mode 100644 index 000000000000..d3132eca51ff --- /dev/null +++ b/src/main/runtime/cursor-approval-prompt-detection.ts @@ -0,0 +1,46 @@ +import { startOfLastLines } from './terminal-wait-tail-window' + +// Why text at all: cursor-agent has no approval hook, so the key-bound menu is the only authority. +const CURSOR_APPROVAL_CHOICE_MARKERS = [ + 'run (once)', + 'to allowlist?', + 'run everything', + 'skip & tell the agent' +] +// Why bounded: an answered menu remains in scrollback; only a dialog owning the screen bottom is live. +const CURSOR_APPROVAL_TAIL_LINES = 8 + +export function findCursorApprovalPromptIndex(normalized: string): number | null { + const windowStart = startOfLastLines(normalized, CURSOR_APPROVAL_TAIL_LINES) + const tail = normalized.slice(windowStart) + if (!tail.includes('run this command?')) { + return null + } + const lines = tail.split('\n') + while (lines.length > 0 && lines.at(-1)?.trim() === '') { + lines.pop() + } + let matchedLines = 0 + let lastChoiceLine = -1 + for (let index = 0; index < lines.length; index += 1) { + if (!isCursorApprovalChoiceLine(lines[index])) { + continue + } + matchedLines += 1 + lastChoiceLine = index + } + return matchedLines >= 2 && lastChoiceLine === lines.length - 1 + ? windowStart + tail.lastIndexOf('run this command?') + : null +} + +// Why the trailing key: narration can repeat the menu wording, but it does not end in a selectable key. +const CURSOR_APPROVAL_CHOICE_KEY_RE = + /\((?:shift\+tab|ctrl\+[a-z]|esc(?: or [a-z])*|tab|enter|return|space|[a-z]|[\u21b5\u21e7\u21b9\u238b\u23ce]{1,3})\)\s*$/ + +function isCursorApprovalChoiceLine(line: string): boolean { + return ( + CURSOR_APPROVAL_CHOICE_KEY_RE.test(line) && + CURSOR_APPROVAL_CHOICE_MARKERS.some((marker) => line.includes(marker)) + ) +} diff --git a/src/main/runtime/local-pty-foreground-command-line.ts b/src/main/runtime/local-pty-foreground-command-line.ts new file mode 100644 index 000000000000..aa836d6f0f2c --- /dev/null +++ b/src/main/runtime/local-pty-foreground-command-line.ts @@ -0,0 +1,17 @@ +import { listRegisteredPtys } from '../memory/pty-registry' +import { resolveAgentForegroundCommandLine } from '../providers/agent-foreground-process' +import { resolveWindowsAgentForegroundCommandLine } from '../providers/windows-agent-foreground-command-line' + +/** Command line of a local PTY's foreground agent, read from this host's process table. */ +export async function readLocalPtyForegroundCommandLine( + ptyId: string, + foregroundProcess: string +): Promise { + const shellPid = listRegisteredPtys().find((pty) => pty.ptyId === ptyId)?.pid + if (!shellPid) { + return null + } + return process.platform === 'win32' + ? resolveWindowsAgentForegroundCommandLine(shellPid, foregroundProcess) + : resolveAgentForegroundCommandLine(shellPid) +} diff --git a/src/main/runtime/opencode-run-lifetime-runtime.test.ts b/src/main/runtime/opencode-run-lifetime-runtime.test.ts new file mode 100644 index 000000000000..4475abdb49fc --- /dev/null +++ b/src/main/runtime/opencode-run-lifetime-runtime.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { readCommandLineMock } = vi.hoisted(() => ({ readCommandLineMock: vi.fn() })) + +vi.mock('./local-pty-foreground-command-line', () => ({ + readLocalPtyForegroundCommandLine: readCommandLineMock +})) + +import { OrcaRuntimeService } from './orca-runtime' +import { FOREGROUND_COMMAND_READS } from '../../shared/foreground-command-settle' +import type { RuntimeTerminalAgentStatusEvent } from './runtime-terminal-contracts' + +const WORKTREE_ID = 'repo::/worktree' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = `tab-1:${LEAF_ID}` + +function createRuntime(): { + runtime: OrcaRuntimeService + statuses: RuntimeTerminalAgentStatusEvent[] + channelOrder: string[] +} { + const statuses: RuntimeTerminalAgentStatusEvent[] = [] + const channelOrder: string[] = [] + const runtime = new OrcaRuntimeService(undefined, undefined, { + onTerminalAgentStatus: (event) => { + statuses.push(event) + channelOrder.push(`status:${event.payload.state}`) + }, + onTerminalSideEffects: (batch) => + channelOrder.push(...batch.facts.map((fact) => `fact:${fact.kind}`)) + }) + runtime.setPtyController({ + spawn: vi.fn(), + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'opencode' + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: WORKTREE_ID, + title: 'Terminal', + activeLeafId: LEAF_ID, + layout: null + } + ], + leaves: [ + { tabId: 'tab-1', worktreeId: WORKTREE_ID, leafId: LEAF_ID, paneRuntimeId: 1, ptyId: 'pty-1' } + ] + }) + return { runtime, statuses, channelOrder } +} + +const summary = (statuses: RuntimeTerminalAgentStatusEvent[]): string[] => + statuses.map( + (event) => + `${event.paneKey}:${event.payload.state}${event.payload.interrupted ? ':interrupted' : ''}:${event.origin}` + ) + +// `opencode run` typed in a pane: the pane's own command boundaries and foreground drive its row. +describe('OpenCode run process lifetime in the runtime', () => { + beforeEach(() => { + vi.useFakeTimers() + readCommandLineMock.mockReset() + readCommandLineMock.mockResolvedValue('opencode run fix the bug') + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('posts Working after the command starts and Done when it finishes, on its own pane', async () => { + const { runtime, statuses } = createRuntime() + + runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100) + await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_READS.settleMs) + runtime.onPtyData('pty-1', 'done\x1b]133;D;0\x07', 101) + + expect(summary(statuses)).toEqual([`${PANE_KEY}:working:process`, `${PANE_KEY}:done:process`]) + expect(readCommandLineMock).toHaveBeenCalledWith('pty-1', 'opencode') + expect(statuses[0]?.yieldsToHookSince).toBe(statuses[1]?.yieldsToHookSince) + }) + + // Why: the renderer drops an exited agent's row on command-finished unless it changed after. + it("publishes the run's Done after the command-finished fact of the same chunk", async () => { + const { runtime, channelOrder } = createRuntime() + + runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100) + await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_READS.settleMs) + runtime.onPtyData('pty-1', 'done\x1b]133;D;0\x07', 101) + + expect(channelOrder).toEqual(['status:working', 'fact:command-finished', 'status:done']) + }) + + it('posts Done from the daemon fact when the pane finished while hidden', async () => { + const { runtime, statuses, channelOrder } = createRuntime() + + runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100) + await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_READS.settleMs) + runtime.emitDaemonPtyTransientFact('pty-1', { kind: 'command-finished', exitCode: 130 }) + + expect(channelOrder.slice(-2)).toEqual(['fact:command-finished', 'status:done']) + expect(summary(statuses)).toEqual([ + `${PANE_KEY}:working:process`, + `${PANE_KEY}:done:interrupted:process` + ]) + }) + + it('stays silent for an SSH pane', async () => { + const { runtime, statuses } = createRuntime() + runtime.registerPty('pty-1', WORKTREE_ID, 'ssh-conn-1') + + runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100) + await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_READS.settleMs) + runtime.onPtyData('pty-1', '\x1b]133;D;0\x07', 101) + + expect(statuses).toEqual([]) + expect(readCommandLineMock).not.toHaveBeenCalled() + }) + + it('reports a local Windows pane from its own foreground', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + const { runtime, statuses } = createRuntime() + readCommandLineMock.mockResolvedValue('"C:\\Tools\\opencode.exe" run fix it') + + runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100) + await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_READS.settleMs) + runtime.onPtyData('pty-1', '\x1b]133;D;0\x07', 101) + + expect(summary(statuses)).toEqual([`${PANE_KEY}:working:process`, `${PANE_KEY}:done:process`]) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) +}) diff --git a/src/main/runtime/opencode-run-lifetime-status.test.ts b/src/main/runtime/opencode-run-lifetime-status.test.ts new file mode 100644 index 000000000000..efaf0d8f0c44 --- /dev/null +++ b/src/main/runtime/opencode-run-lifetime-status.test.ts @@ -0,0 +1,234 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' +import { FOREGROUND_COMMAND_READS } from '../../shared/foreground-command-settle' +import { OpenCodeRunLifetimeStatus } from './opencode-run-lifetime-status' + +type Published = { ptyId: string; payload: ParsedAgentStatusPayload; yieldsToHookSince: number } + +function setup( + options: { + name?: string | null + commandLine?: string | null + observable?: boolean + enabledAgents?: readonly string[] + } = {} +) { + const published: Published[] = [] + const readForegroundProcessName = vi.fn(async () => + options.name === undefined ? 'opencode' : options.name + ) + const readForegroundCommandLine = vi.fn(async () => + options.commandLine === undefined ? 'opencode run fix the bug' : options.commandLine + ) + let clock = 1_000 + const lifetime = new OpenCodeRunLifetimeStatus({ + isObservablePty: () => options.observable ?? true, + isStatusEnabled: (agent) => + (options.enabledAgents ?? ['opencode', 'opencode2']).includes(agent), + readForegroundProcessName, + readForegroundCommandLine, + publish: (ptyId, payload, yieldsToHookSince) => + published.push({ ptyId, payload, yieldsToHookSince }), + now: () => clock++ + }) + const states = (): string[] => + published.map(({ payload }) => + payload.interrupted ? `${payload.state}:interrupted` : payload.state + ) + return { lifetime, published, states, readForegroundProcessName, readForegroundCommandLine } +} + +async function settle(): Promise { + await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_READS.settleMs) +} + +describe('OpenCodeRunLifetimeStatus', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('shows Working while `opencode run` holds the foreground and Done when it exits', async () => { + const { lifetime, published, states } = setup() + lifetime.onCommandStarted('pty-1') + expect(states()).toEqual([]) + await settle() + expect(states()).toEqual(['working']) + lifetime.onCommandFinished('pty-1', 0) + expect(states()).toEqual(['working', 'done']) + expect(published.map((entry) => entry.ptyId)).toEqual(['pty-1', 'pty-1']) + expect(published.map((entry) => entry.payload.agentType)).toEqual(['opencode', 'opencode']) + // Both writes yield to a hook that reports after this command started. + expect(new Set(published.map((entry) => entry.yieldsToHookSince))).toEqual(new Set([1_000])) + }) + + it('reports `opencode2 run` as OpenCode 2', async () => { + const { lifetime, published } = setup({ + name: 'opencode2', + commandLine: '/usr/local/bin/opencode2 run --model a/b hi' + }) + lifetime.onCommandStarted('pty-1') + await settle() + expect(published[0]?.payload.agentType).toBe('opencode2') + }) + + it('marks a Ctrl-C exit as interrupted and any other exit as a plain Done', async () => { + const { lifetime, states } = setup() + lifetime.onCommandStarted('pty-1') + await settle() + lifetime.onCommandFinished('pty-1', 130) + lifetime.onCommandStarted('pty-1') + await settle() + lifetime.onCommandFinished('pty-1', 1) + expect(states()).toEqual(['working', 'done:interrupted', 'working', 'done']) + }) + + it('reads no argv unless the foreground process is OpenCode', async () => { + const { lifetime, states, readForegroundCommandLine } = setup({ name: 'npm' }) + lifetime.onCommandStarted('pty-1') + await settle() + lifetime.onCommandFinished('pty-1', 0) + expect(readForegroundCommandLine).not.toHaveBeenCalled() + expect(states()).toEqual([]) + }) + + it.each(['zsh', 'npx', '/usr/local/bin/node', 'bun.exe'])( + 're-reads on the shared ladder while the foreground is %s, until OpenCode execs', + async (firstName) => { + const { lifetime, states, readForegroundProcessName } = setup() + readForegroundProcessName.mockResolvedValueOnce(firstName) + lifetime.onCommandStarted('pty-1') + await settle() + expect(states()).toEqual([]) + await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_READS.retryDelaysMs[0]) + expect(states()).toEqual(['working']) + expect(readForegroundProcessName).toHaveBeenCalledTimes(2) + } + ) + + it('stops re-reading after the last rung for a shell or launcher that never becomes OpenCode', async () => { + for (const name of ['node', 'bash']) { + const { lifetime, readForegroundProcessName } = setup({ name }) + lifetime.onCommandStarted('pty-1') + await vi.advanceTimersByTimeAsync(60_000) + expect(readForegroundProcessName).toHaveBeenCalledTimes( + 1 + FOREGROUND_COMMAND_READS.retryDelaysMs.length + ) + } + }) + + // Why: another agent, an editor or a dev server never execs OpenCode as the pane's foreground. + it.each(['claude', 'codex', 'vim', 'sleep', 'python3'])( + 'reads %s once and stops', + async (name) => { + const { lifetime, states, readForegroundProcessName } = setup({ name }) + lifetime.onCommandStarted('pty-1') + await vi.advanceTimersByTimeAsync(60_000) + expect(readForegroundProcessName).toHaveBeenCalledTimes(1) + expect(states()).toEqual([]) + } + ) + + it('leaves the OpenCode TUI and its other subcommands to their own reporters', async () => { + for (const commandLine of ['opencode', 'opencode serve', 'opencode attach http://x', null]) { + const { lifetime, states } = setup({ commandLine }) + lifetime.onCommandStarted('pty-1') + await settle() + lifetime.onCommandFinished('pty-1', 0) + expect(states()).toEqual([]) + } + }) + + it('ignores a command line whose agent is not the foreground name', async () => { + const { lifetime, states } = setup({ name: 'opencode2', commandLine: 'opencode run hi' }) + lifetime.onCommandStarted('pty-1') + await settle() + expect(states()).toEqual([]) + }) + + it('stays silent for a command that finishes before the settle read (`&`, fast exit)', async () => { + const { lifetime, states, readForegroundProcessName } = setup() + lifetime.onCommandStarted('pty-1') + lifetime.onCommandFinished('pty-1', 0) + await settle() + expect(readForegroundProcessName).not.toHaveBeenCalled() + expect(states()).toEqual([]) + }) + + it('never arms from a read that a newer command superseded', async () => { + let resolveName: (name: string) => void = () => {} + const { lifetime, states, readForegroundProcessName } = setup() + readForegroundProcessName.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveName = resolve + }) + ) + lifetime.onCommandStarted('pty-1') + await settle() + lifetime.onCommandFinished('pty-1', 0) + resolveName('opencode') + await vi.runAllTimersAsync() + expect(states()).toEqual([]) + }) + + it('stays silent when OpenCode status is turned off for that agent', async () => { + const { lifetime, states, readForegroundCommandLine } = setup({ enabledAgents: ['opencode2'] }) + lifetime.onCommandStarted('pty-1') + await settle() + lifetime.onCommandFinished('pty-1', 0) + expect(readForegroundCommandLine).not.toHaveBeenCalled() + expect(states()).toEqual([]) + }) + + it('sets no timer and reads nothing while status is off for both OpenCode agents', () => { + const { lifetime, readForegroundProcessName } = setup({ enabledAgents: [] }) + lifetime.onCommandStarted('pty-1') + expect(vi.getTimerCount()).toBe(0) + expect(readForegroundProcessName).not.toHaveBeenCalled() + }) + + it('reads nothing for a pane whose foreground is on another host', async () => { + const { lifetime, states, readForegroundProcessName } = setup({ observable: false }) + lifetime.onCommandStarted('pty-1') + await settle() + lifetime.onCommandFinished('pty-1', 0) + expect(readForegroundProcessName).not.toHaveBeenCalled() + expect(states()).toEqual([]) + }) + + it('ends an armed run with Done when the next command starts without its 133;D', async () => { + const { lifetime, states } = setup() + lifetime.onCommandStarted('pty-1') + await settle() + lifetime.onCommandStarted('pty-1') + expect(states()).toEqual(['working', 'done']) + await settle() + expect(states()).toEqual(['working', 'done', 'working']) + }) + + it('posts no Done for a pane torn down mid-run', async () => { + const { lifetime, states } = setup() + lifetime.onCommandStarted('pty-1') + await settle() + lifetime.forgetPty('pty-1') + lifetime.onCommandFinished('pty-1', 0) + expect(states()).toEqual(['working']) + }) + + it('keeps each pane to its own command', async () => { + const { lifetime, published } = setup() + lifetime.onCommandStarted('pty-1') + lifetime.onCommandStarted('pty-2') + await settle() + lifetime.onCommandFinished('pty-2', 0) + expect(published.map((entry) => `${entry.ptyId}:${entry.payload.state}`)).toEqual([ + 'pty-1:working', + 'pty-2:working', + 'pty-2:done' + ]) + }) +}) diff --git a/src/main/runtime/opencode-run-lifetime-status.ts b/src/main/runtime/opencode-run-lifetime-status.ts new file mode 100644 index 000000000000..304e020f31ef --- /dev/null +++ b/src/main/runtime/opencode-run-lifetime-status.ts @@ -0,0 +1,158 @@ +import { tokenizeCommandLine } from '../../shared/agent-command-line-entrypoint' +import { recognizeAgentProcess } from '../../shared/agent-process-recognition' +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../shared/agent-status-types' +import { FOREGROUND_COMMAND_READS } from '../../shared/foreground-command-settle' +import { isOpenCodeRunCommand } from '../../shared/opencode-headless-command' +import { isShellProcess } from '../../shared/shell-process-detection' + +const SIGINT_EXIT_CODE = 130 +// Launchers that can still exec OpenCode after the first read (`npx`/`bunx opencode-ai run`). +const OPENCODE_LAUNCHERS = new Set(['node', 'bun', 'bunx', 'npx', 'npm', 'pnpm', 'pnpx', 'yarn']) + +// Why these only: a shell means the command has not exec'd yet, and a launcher may still exec +// OpenCode; any other program (another agent, vim, a dev server) never becomes OpenCode. +function mayStillBecomeOpenCode(processName: string): boolean { + if (recognizeAgentProcess(processName) !== null) { + return false + } + const base = (processName.split(/[\\/]/).pop() ?? '').toLowerCase().replace(/\.(exe|cmd)$/, '') + return isShellProcess(processName) || OPENCODE_LAUNCHERS.has(base) +} + +type OpenCodeAgent = 'opencode' | 'opencode2' + +type Dependencies = { + /** Local PTYs only: SSH and WSL foregrounds cannot be read on this host. */ + isObservablePty(ptyId: string): boolean + /** The per-agent status switch the plugin install honours (#23667). */ + isStatusEnabled(agent: OpenCodeAgent): boolean + readForegroundProcessName(ptyId: string): Promise + readForegroundCommandLine(ptyId: string, foregroundProcess: string): Promise + /** `yieldsToHookSince`: the store drops this write once a hook reported the pane since then. */ + publish(ptyId: string, payload: ParsedAgentStatusPayload, yieldsToHookSince: number): void + now(): number +} + +type CommandState = { + generation: number + startedAt: number + timer: ReturnType | null + armed: OpenCodeAgent | null +} + +/** + * Reports `opencode run` from its own process lifetime: Working once the pane's foreground + * command is an OpenCode `run`, Done when that command finishes. OpenCode 2's `run` loads no + * plugin, so nothing else can say which pane it runs in. + */ +export class OpenCodeRunLifetimeStatus { + private readonly commands = new Map() + private nextGeneration = 0 + + constructor(private readonly deps: Dependencies) {} + + onCommandStarted(ptyId: string): void { + // Why: a new command proves the armed one ended even though its 133;D never arrived. + this.onCommandFinished(ptyId, null) + if ( + !this.deps.isObservablePty(ptyId) || + (!this.deps.isStatusEnabled('opencode') && !this.deps.isStatusEnabled('opencode2')) + ) { + return + } + const state: CommandState = { + generation: ++this.nextGeneration, + startedAt: this.deps.now(), + timer: null, + armed: null + } + this.commands.set(ptyId, state) + this.scheduleInspect(ptyId, state, FOREGROUND_COMMAND_READS.settleMs, 0) + } + + onCommandFinished(ptyId: string, exitCode: number | null): void { + const state = this.commands.get(ptyId) + this.forgetPty(ptyId) + if (!state?.armed) { + return + } + const payload = normalizeAgentStatusPayload({ + state: 'done', + prompt: '', + agentType: state.armed, + ...(exitCode === SIGINT_EXIT_CODE ? { interrupted: true } : {}) + }) + if (payload) { + this.deps.publish(ptyId, payload, state.startedAt) + } + } + + forgetPty(ptyId: string): void { + const state = this.commands.get(ptyId) + if (state?.timer) { + clearTimeout(state.timer) + } + this.commands.delete(ptyId) + } + + private scheduleInspect( + ptyId: string, + state: CommandState, + delayMs: number, + retryIndex: number + ): void { + state.timer = setTimeout(() => { + state.timer = null + void this.inspect(ptyId, state, retryIndex) + }, delayMs) + } + + private isCurrent(ptyId: string, state: CommandState): boolean { + return this.commands.get(ptyId)?.generation === state.generation + } + + private async inspect(ptyId: string, state: CommandState, retryIndex: number): Promise { + try { + const name = await this.deps.readForegroundProcessName(ptyId) + const agent = recognizeAgentProcess(name)?.agent + if (!name || !this.isCurrent(ptyId, state)) { + return + } + if (agent !== 'opencode' && agent !== 'opencode2') { + const retryDelay = FOREGROUND_COMMAND_READS.retryDelaysMs[retryIndex] + if (retryDelay !== undefined && mayStillBecomeOpenCode(name)) { + this.scheduleInspect(ptyId, state, retryDelay, retryIndex + 1) + } + return + } + if (!this.deps.isStatusEnabled(agent)) { + return + } + const tokens = tokenizeCommandLine( + (await this.deps.readForegroundCommandLine(ptyId, name)) ?? '' + ) + if ( + !this.isCurrent(ptyId, state) || + recognizeAgentProcess(tokens[0])?.agent !== agent || + !isOpenCodeRunCommand(tokens) + ) { + return + } + const payload = normalizeAgentStatusPayload({ + state: 'working', + prompt: '', + agentType: agent + }) + if (!payload) { + return + } + state.armed = agent + this.deps.publish(ptyId, payload, state.startedAt) + } catch { + // Why: a failed read is missing evidence; the pane stays silent rather than guessed. + } + } +} diff --git a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts index f4f8826e6e78..2976c4dff9b9 100644 --- a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts +++ b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts @@ -158,6 +158,7 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.dispose() this.ptyTitleTrackersByPtyId.delete(ptyId) this.ptyForegroundAgent.clearDelayedSnapshot(ptyId) + this.openCodeRunLifetime.forgetPty(ptyId) this.mobileSessionTabsAgentStatusHeartbeat.removePty(ptyId) this.clientEvents.clearPtyTitleGate(ptyId) } diff --git a/src/main/runtime/orca-runtime-background-removal-test-support.ts b/src/main/runtime/orca-runtime-background-removal-test-support.ts new file mode 100644 index 000000000000..811c31f36fc6 --- /dev/null +++ b/src/main/runtime/orca-runtime-background-removal-test-support.ts @@ -0,0 +1,20 @@ +import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' +import type { RemoveManagedWorktreeOptions } from './runtime-worktree-selection' + +type RemovalRuntimePrototype = { + removeManagedWorktree: ( + selector: string, + options?: RemoveManagedWorktreeOptions + ) => Promise +} + +/** + * Makes `removeManagedWorktree` wait for its background delete, as a current client does, so suites + * about what the delete does keep asserting its end state. A test can still pass `false`. + */ +export function awaitBackgroundRemovalsInRuntimeTests(prototype: RemovalRuntimePrototype): void { + const remove = prototype.removeManagedWorktree + prototype.removeManagedWorktree = function (selector, options = {}) { + return remove.call(this, selector, { waitForBackgroundRemoval: true, ...options }) + } +} diff --git a/src/main/runtime/orca-runtime-close-mobile-session-tab.ts b/src/main/runtime/orca-runtime-close-mobile-session-tab.ts index 3f7047c64876..6f4248a4673e 100644 --- a/src/main/runtime/orca-runtime-close-mobile-session-tab.ts +++ b/src/main/runtime/orca-runtime-close-mobile-session-tab.ts @@ -20,6 +20,7 @@ import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry' import type { RuntimeCommandSurfaceHost } from './orca-runtime-core' import { SESSION_TAB_NOT_FOUND_ERROR } from '../../shared/session-tab-close' import { rendererPublicationThrottle } from '../window/renderer-publication-throttle' +import { structuredAgentSessionTabCloseCause } from './structured-agent-session-tab-close-cause' export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseUnattributedMobileSessionTabClose { async closeMobileSessionTab( @@ -300,7 +301,10 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU } } } - await this.closeStructuredAgentSessionTab(tab) + await this.closeStructuredAgentSessionTab( + tab, + structuredAgentSessionTabCloseCause(options.reason) + ) } else { if (!this.notifier?.closeSessionTab) { throw new Error('runtime_unavailable') diff --git a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts index 568083bf177f..3b3510916ba2 100644 --- a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts +++ b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts @@ -13,20 +13,28 @@ import type { BrowserSessionTabSelectionOptions } from './browser-tab-create-pub import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry' import { applyBrowserSessionTabSelection } from './browser-session-tab-selection-snapshot' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import type { StructuredAgentSessionCloseCause } from '../native-chat/agent-session-wire/structured-agent-session-host-lifetime' import { retireStructuredAgentSessionTabFrom } from './structured-agent-session-tab-retirement' export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWithCloseMobileSessionTab { - protected async closeStructuredAgentSessionTab(tab: RuntimeMobileSessionAgentTab): Promise { + protected async closeStructuredAgentSessionTab( + tab: RuntimeMobileSessionAgentTab, + cause: StructuredAgentSessionCloseCause + ): Promise { const host = getStructuredAgentSessionHost() if (host) { if (typeof host.setSessionTabVisibility === 'function') { - await host.setSessionTabVisibility(tab.sessionId, false) + // The restore index is bookkeeping: one that cannot be written (a newer Orca's records, a + // failing disk) is reported, and the tab still closes. + await host.setSessionTabVisibility(tab.sessionId, false).catch((error: unknown) => { + console.warn('[structured-agent-session] recording a closed chat tab failed', error) + }) } } // Retire durable visibility and the runtime snapshot before stopping the provider. this.retireStructuredAgentSessionTabFromSnapshot(tab.sessionId) if (typeof host?.close === 'function') { - await host.close(tab.sessionId) + await host.close(tab.sessionId, cause) } } diff --git a/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts b/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts index 4ebeed55f903..d417fc7434f9 100644 --- a/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts +++ b/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts @@ -159,6 +159,11 @@ export class OrcaRuntimeWithCreatePtyHeadlessTerminalState extends OrcaRuntimeWi if (!state) { return } + const unpainted = state.unrepaintedReflowGrid + // Why: a PTY resize off the reflowed grid makes the TUI repaint; an echo of it does not. + if (unpainted && (unpainted.cols !== cols || unpainted.rows !== rows)) { + state.unrepaintedReflowGrid = undefined + } // Why: terminal reflow is a parser operation. It must sit in the same // per-PTY stream as output bytes or restore snapshots can bake in wraps // from the wrong terminal width. @@ -180,7 +185,16 @@ export class OrcaRuntimeWithCreatePtyHeadlessTerminalState extends OrcaRuntimeWi if (cols <= 0 || rows <= 0) { return } + const state = this.headlessTerminals.get(ptyId) + if (!state) { + return + } + const applied = state.emulator.getAppliedSize() this.resizeHeadlessTerminal(ptyId, cols, rows) + // Why: nothing resized the PTY, so the TUI does not repaint and its cells keep the old grid. + if (applied.cols !== cols || applied.rows !== rows) { + state.unrepaintedReflowGrid = { cols, rows } + } } // Public: desktop-initiated clears (ipc/pty.ts) must also drop this mobile diff --git a/src/main/runtime/orca-runtime-create-terminal-side-effect-command-code-detector.ts b/src/main/runtime/orca-runtime-create-terminal-side-effect-command-code-detector.ts index 5486e9539892..38ccd1b6ce72 100644 --- a/src/main/runtime/orca-runtime-create-terminal-side-effect-command-code-detector.ts +++ b/src/main/runtime/orca-runtime-create-terminal-side-effect-command-code-detector.ts @@ -85,7 +85,11 @@ export class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector extends return worktreePath && isWindowsAbsolutePathLike(worktreePath) ? 'win32' : 'posix' } - protected emitTerminalAgentStatusEvents(ptyId: string, chunk: ProcessedAgentStatusChunk): void { + protected emitTerminalAgentStatusEvents( + ptyId: string, + chunk: Pick, + provenance?: { origin: 'process'; yieldsToHookSince: number } + ): void { if (chunk.payloads.length === 0) { return } @@ -146,7 +150,8 @@ export class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector extends this.onTerminalAgentStatus({ ptyId, ...target, - payload + payload, + ...provenance }) } catch (err) { console.error('[runtime] terminal agent status listener threw', { diff --git a/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts b/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts index cc112a78ade7..dbbb124d34aa 100644 --- a/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts +++ b/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts @@ -23,6 +23,7 @@ export class OrcaRuntimeWithEmitDaemonPtyTransientFact extends OrcaRuntimeWithSc kind: 'command-finished', exitCode: fact.exitCode }) + this.openCodeRunLifetime.onCommandFinished(ptyId, fact.exitCode) return case 'pr-link': this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link: fact.link }) diff --git a/src/main/runtime/orca-runtime-file-commands.ts b/src/main/runtime/orca-runtime-file-commands.ts index 56721971132a..b504d6198f78 100644 --- a/src/main/runtime/orca-runtime-file-commands.ts +++ b/src/main/runtime/orca-runtime-file-commands.ts @@ -44,17 +44,24 @@ export class OrcaRuntimeWithFileCommands extends OrcaRuntimeWithPreservedBranchC absolutePath }), resolveRuntimeGitTarget: (selector) => this.resolveRuntimeGitTarget(selector), - openFile: (worktreeId, filePath, relativePath, runtimeEnvironmentId) => { + openFile: (worktreeId, filePath, relativePath, runtimeEnvironmentId, navigation) => { if (!this.notifier?.openFile) { throw new Error('renderer_unavailable') } - this.notifier.openFile(worktreeId, filePath, relativePath, runtimeEnvironmentId) + this.notifier.openFile(worktreeId, filePath, relativePath, runtimeEnvironmentId, navigation) }, - openDiff: (worktreeId, filePath, relativePath, staged, runtimeEnvironmentId) => { + openDiff: (worktreeId, filePath, relativePath, staged, runtimeEnvironmentId, navigation) => { if (!this.notifier?.openDiff) { throw new Error('renderer_unavailable') } - this.notifier.openDiff(worktreeId, filePath, relativePath, staged, runtimeEnvironmentId) + this.notifier.openDiff( + worktreeId, + filePath, + relativePath, + staged, + runtimeEnvironmentId, + navigation + ) } }) diff --git a/src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts b/src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts index fb136c783e74..a1086ae85801 100644 --- a/src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts +++ b/src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts @@ -56,6 +56,7 @@ describe('RuntimeFileCommands', () => { '/repo/docs/readme.md', 'docs/readme.md', true, + undefined, undefined ) expect(result).toEqual({ @@ -78,6 +79,7 @@ describe('RuntimeFileCommands', () => { 'wt-1', '/repo/docs/readme.md', 'docs/readme.md', + undefined, undefined ) expect(result).toEqual({ @@ -100,6 +102,7 @@ describe('RuntimeFileCommands', () => { 'wt-1', '/repo/assets/logo.png', 'assets/logo.png', + undefined, undefined ) expect(result).toEqual({ @@ -110,6 +113,33 @@ describe('RuntimeFileCommands', () => { }) }) + it('passes the caller navigation target to the renderer host', async () => { + const openFile = vi.fn() + const openDiff = vi.fn() + const { commands } = createRuntimeFileCommands({ openFile, openDiff }) + resolveAuthorizedPathMock.mockResolvedValue('/repo/docs/readme.md') + statMock.mockResolvedValue({ isDirectory: () => false }) + + await commands.openMobileFile('id:wt-1', 'docs/readme.md', 'all') + await commands.openMobileDiff('id:wt-1', 'docs/readme.md', false, 'host') + + expect(openFile).toHaveBeenCalledWith( + 'wt-1', + '/repo/docs/readme.md', + 'docs/readme.md', + undefined, + 'all' + ) + expect(openDiff).toHaveBeenCalledWith( + 'wt-1', + '/repo/docs/readme.md', + 'docs/readme.md', + false, + undefined, + 'host' + ) + }) + it('leaves non-previewable binaries unavailable on mobile', async () => { const openFile = vi.fn() const { commands } = createRuntimeFileCommands({ openFile }) diff --git a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts index 3ece144bc248..2e6f30770b0f 100644 --- a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts +++ b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts @@ -1,6 +1,6 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithEmitDaemonPtyTransientFact } from './orca-runtime-emit-daemon-pty-transient-fact' -import { getDecorativeAgentTitleSignature } from '../../shared/agent-decorative-title-signature' +import { getDecorativeTitleGateKey } from '../../shared/agent-decorative-title-signature' import { shouldEmitTitleFactForFrame } from './decorative-title-fact-emission' import type { RuntimePtyTitleTrackerEntry } from './runtime-terminal-state-records' import { createTerminalTitleTracker } from '../../shared/terminal-output-side-effects' @@ -37,11 +37,7 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime /** Decorative comparison key: only recognized agent titles fold leading spinner frames. */ protected makeDecorativeTitleGateKey(rawTitle: string, normalizedTitle: string): string { - // Stable Pi/Gemini/Grok display normalization also defines their semantic gate. - const normalizedSignature = - rawTitle === normalizedTitle ? null : getDecorativeAgentTitleSignature(normalizedTitle) - const signature = normalizedSignature ?? getDecorativeAgentTitleSignature(rawTitle) - return signature === null ? `literal\u0000${normalizedTitle}` : `agent\u0000${signature}` + return getDecorativeTitleGateKey(rawTitle, normalizedTitle) } protected getOrCreatePtyTitleTrackerEntry(ptyId: string): RuntimePtyTitleTrackerEntry { @@ -68,6 +64,9 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime const live = this.ptyTitleTrackersByPtyId.get(ptyId) const gateKey = this.makeDecorativeTitleGateKey(rawTitle, normalizedTitle) const decorativeOnly = live?.lastMobileTitleGateKey === gateKey + if (!decorativeOnly && !meta?.staleWorkingTitleClear) { + void this.recheckHookAgentPresenceForPty(ptyId) + } if (live) { live.lastMobileTitleGateKey = gateKey } @@ -143,9 +142,14 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime onAgentExited: () => { this.confirmPtyAgentExit(ptyId) }, + onCommandStarted: () => { + this.openCodeRunLifetime.onCommandStarted(ptyId) + }, onCommandFinished: (exitCode: number | null) => { + void this.recheckHookAgentPresenceForPty(ptyId) this.retirePtyAgentLaunchAuthority(ptyId) this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode }) + this.openCodeRunLifetime.onCommandFinished(ptyId, exitCode) }, onBell: () => { this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) @@ -171,6 +175,7 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime lastTitleFactAtMs: null, chunkTouchedSessionTabs: false, pendingFacts: [], + afterFacts: [], // Why: command-code facts exist only for the pty:sideEffect channel — // headless serve skips the per-chunk scrape entirely. The detector // self-arms on the Command Code banner; the spawn command (when main diff --git a/src/main/runtime/orca-runtime-mark-pty-liveness-unverifiable.ts b/src/main/runtime/orca-runtime-mark-pty-liveness-unverifiable.ts index c4e4324e47d3..446083fff030 100644 --- a/src/main/runtime/orca-runtime-mark-pty-liveness-unverifiable.ts +++ b/src/main/runtime/orca-runtime-mark-pty-liveness-unverifiable.ts @@ -28,6 +28,9 @@ export class OrcaRuntimeWithMarkPtyLivenessUnverifiable extends OrcaRuntimeWithO if (observedNoLaterThan !== undefined && tracked && tracked.observedAt > observedNoLaterThan) { return } + if (tracked?.verdict.status === 'unverifiable') { + void this.recheckHookAgentPresenceForPty(ptyId) + } this.rememberPtyLivenessVerdict(ptyId, { status: 'live', ptyIds: [ptyId] }) } diff --git a/src/main/runtime/orca-runtime-notify-ssh-state-changed.ts b/src/main/runtime/orca-runtime-notify-ssh-state-changed.ts index 4a60ab458a46..b321a62f1cce 100644 --- a/src/main/runtime/orca-runtime-notify-ssh-state-changed.ts +++ b/src/main/runtime/orca-runtime-notify-ssh-state-changed.ts @@ -134,6 +134,12 @@ export class OrcaRuntimeWithNotifySshStateChanged extends OrcaRuntimeWithGetStat this.emitClientEvent({ type: 'worktreesChanged', repoId }) } + /** A background removal started or ended; every client refetches and reads the `removing` marker. */ + publishWorktreeRemovalChange(repoId: string): void { + this.invalidateResolvedWorktreeCache() + this.notifyWorktreesChanged(repoId) + } + // Why: structural catalog changes require a fresh Git scan; renderer metadata edits do not. notifyWorktreeCatalogChangedForRemoteClients(repoId: string): void { this.invalidateWorktreeScanCacheForRepo(repoId) diff --git a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts index 404e09bf085e..37f8121f99ae 100644 --- a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts @@ -90,6 +90,10 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin protected readonly retireAgentHookCompatibilityAuthorityFn: ((paneKey: string) => void) | null + protected readonly checkHookAgentPresenceFn: + | ((paneKey: string) => Promise<'live' | 'unverifiable' | 'exited' | null>) + | null + protected readonly reconcileAgentStatusForEndedProcessFn: | ((paneKeys: Iterable) => void) | null diff --git a/src/main/runtime/orca-runtime-remove-managed-worktree.ts b/src/main/runtime/orca-runtime-remove-managed-worktree.ts index 24e733046733..dc065bd82248 100644 --- a/src/main/runtime/orca-runtime-remove-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-remove-managed-worktree.ts @@ -6,13 +6,16 @@ import { } from '../worktree-removal-repo-owner' import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host' +import { + finishAcceptedWorktreeRemoval, + waitForPendingWorktreeRemoval +} from '../worktree-background-removal' import { preservedBranchCleanupScopeKey } from '../../shared/preserved-branch-cleanup' import { getRuntimeWorktreeRemovalOptionsKey, type RemoveManagedWorktreeOptions } from './runtime-worktree-selection' import { withWorktreeSpan } from '../observability/instrumentation' -import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { resolveWorktreeRemovalHome, resolveWorktreeRemovalRoute @@ -50,6 +53,17 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM const store = this.store const cleanupHostId = parseExecutionHostId(hostId)?.id const removalTarget = await this.resolveWorktreeRemovalTarget(worktreeSelector, cleanupHostId) + // Why: a retry or a second client asking while Git still deletes joins that removal. + const pending = waitForPendingWorktreeRemoval(removalTarget.id, cleanupHostId) + if (pending) { + return options.waitForBackgroundRemoval ? await pending : { removing: true } + } + const emitRemoved = (): void => + this.emitWorktreeLifecycle({ + kind: 'removed', + worktreeId: removalTarget.id, + path: removalTarget.path + }) const cleanupScopeKey = preservedBranchCleanupScopeKey({ worktreeId: removalTarget.id, hostId: cleanupHostId @@ -66,7 +80,9 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM optionsKey ) if (inFlightRemoval) { - return inFlightRemoval + return options.waitForBackgroundRemoval + ? finishAcceptedWorktreeRemoval(await inFlightRemoval, removalTarget.id, cleanupHostId) + : inFlightRemoval } const removal = (async (): Promise => { return withWorktreeSpan({ stage: 'remove', path: removalTarget.path }, async () => { @@ -82,6 +98,8 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM } const repo = repoOwner.kind === 'resolved' ? repoOwner.repo : undefined const removalHostId = repo ? (cleanupHostId ?? getRepoExecutionHostId(repo)) : cleanupHostId + const purgeRemovedWorktree = (): void => + this.purgeRemovedWorktree(store, removalTarget.id, removalTarget.repoId, removalHostId) const orphanOrFolderResult = await removeOrphanOrFolderWorktree({ runtime: this, store, @@ -140,12 +158,8 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM removalTarget.id ), finishRemoval: () => { - this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId) this.preservedBranchCleanup.delete(removalTarget.id, cleanupHostId) - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) - invalidateAuthorizedRootsCache() + purgeRemovedWorktree() this.notifyWorktreesChanged(repo.id) } }) @@ -192,11 +206,7 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM registeredWorktree.head, removedPushTarget ) - this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId) - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) - invalidateAuthorizedRootsCache() + purgeRemovedWorktree() this.notifyWorktreesChanged(repo.id) return removalResult ?? {} } @@ -235,11 +245,7 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM registeredWorktree.head, removedPushTarget ) - this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId) - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) - invalidateAuthorizedRootsCache() + purgeRemovedWorktree() this.notifyWorktreesChanged(repo.id) } }) @@ -277,25 +283,23 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM } else { this.preservedBranchCleanup.delete(removalTarget.id, cleanupHostId) } - this.clearOptimisticReconcileToken(removalTarget.id) - this.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId) - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) - invalidateAuthorizedRootsCache() - this.notifyWorktreesChanged(repo.id) - } + purgeRemovedWorktree() + }, + onRemoved: emitRemoved, + publish: () => this.publishWorktreeRemovalChange(repo.id) }) }) })() this.removeManagedWorktreeInFlight.track(cleanupScopeKey, optionsKey, removal) try { - const result = await removal - this.emitWorktreeLifecycle({ - kind: 'removed', - worktreeId: removalTarget.id, - path: removalTarget.path - }) - return result + const accepted = await removal + // A background removal emits this when Git finishes instead. + if (!accepted.removing) { + emitRemoved() + } + return options.waitForBackgroundRemoval + ? finishAcceptedWorktreeRemoval(accepted, removalTarget.id, cleanupHostId) + : accepted } finally { this.removeManagedWorktreeInFlight.release(cleanupScopeKey, removal) } diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index 0aeacdc0e99e..7fef1bdf9db1 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -22,6 +22,10 @@ import { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution import { buildAgentStartupPlan } from '../../shared/tui-agent-startup' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' +import { resumeInterruptedWorktreeRemovals } from '../worktree-background-removal' +import { interruptedLocalWorktreeRemovalJob } from './runtime-interrupted-local-worktree-removal' export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWithRemoveManagedWorktree { protected async resolveWorktreeRemovalTarget( @@ -38,6 +42,41 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith }) } + /** Runs the same delete again for each local removal a quit or crash interrupted. */ + finishInterruptedWorktreeRemovals(): void { + const store = this.store + if (!store) { + return + } + resumeInterruptedWorktreeRemovals((record) => + interruptedLocalWorktreeRemovalJob(record, { + store, + acquireWatcherRemoval: this.acquireFileWatcherRemoval, + closeWatchers: (path) => this.closeFileWatchersForRemoval(path), + preservedBranchCleanup: this.preservedBranchCleanup, + purge: ({ worktreeId, repoId }) => + this.purgeRemovedWorktree(store, worktreeId, repoId, LOCAL_EXECUTION_HOST_ID), + onRemoved: ({ worktreeId, worktreePath }) => + this.emitWorktreeLifecycle({ kind: 'removed', worktreeId, path: worktreePath }), + publish: (repoId) => this.publishWorktreeRemovalChange(repoId) + }) + ) + } + + // Host state every removal path drops once Git has let go of the checkout. + protected purgeRemovedWorktree( + store: RuntimeStore, + worktreeId: string, + repoId: string, + removalHostId?: ExecutionHostId + ): void { + this.clearOptimisticReconcileToken(worktreeId) + this.removeWorktreeMetadataAndHistory(store, worktreeId, removalHostId) + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(repoId) + invalidateAuthorizedRootsCache() + } + protected removeWorktreeMetadataAndHistory( store: RuntimeStore, worktreeId: string, diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index 8d29a0d360b9..f8845221e66a 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -5,6 +5,7 @@ import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement' import type { ConversationReplacement } from '../native-chat/agent-session-wire/structured-conversation-command' import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration' +import { seedStructuredAgentSessionTabIndex } from './structured-agent-session-tab-index-seed' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { RuntimeMobileSessionAgentTab, @@ -28,7 +29,9 @@ import { parseAppSshPtyId } from '../../shared/ssh-pty-id' import type { PtyProcessInspection } from '../providers/pty-process-inspection' export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRuntimeWithGetStructuredAgentSessionCreateSupport { - async replaceStructuredAgentSessionTab(replacement: ConversationReplacement): Promise { + /** Projects only: a replacement's chat already has its tab in the store, which the /clear commit + * moved in the same write. */ + replaceStructuredAgentSessionTab(replacement: ConversationReplacement): void { const prior = this.mobileSessionTabsByWorktree.get(replacement.workspaceId) const next = prior ? replaceConversationInSnapshot(prior, replacement) : null if (next && next !== prior) { @@ -39,7 +42,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu (tab) => tab.type === 'agent-session' && tab.sessionId === replacement.sessionId ) ) { - await this.publishStructuredAgentSessionTab({ + this.projectStructuredAgentSessionTab({ ...replacement, replacesSessionId: replacement.sourceSessionId, activate: false @@ -57,9 +60,12 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu const profileIds = collectSavedStructuredAgentSessionIds( this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) ?? null ) - await host?.restoreReadableSessions( - persistedVisibleIndex.present ? persistedVisibleIndex.sessionIds : profileIds - ) + // Unrecorded, the profile's chats join the tabs chats opened while the import was owed left. + // First: after a /clear the profile's chat would take their tab id, so seeds hit tabIdTaken. + const targets = persistedVisibleIndex.present + ? persistedVisibleIndex.sessionIds + : [...new Set([...persistedVisibleIndex.sessionIds, ...profileIds])] + await host?.restoreReadableSessions(targets) for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { allowAttachedWindow: true, @@ -67,29 +73,35 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu }) } this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() - for (const replacement of host?.conversationReplacements?.() ?? []) { - await this.replaceStructuredAgentSessionTab(replacement) - } - for (const session of host?.listSessionTabs() ?? []) { + const restored = (host?.listSessionTabs() ?? []).flatMap((session) => { if (session.agent !== 'codex' && session.agent !== 'claude') { - continue + return [] } let sessionId = session.sessionId while (sessionId.startsWith('agent-session:')) { sessionId = sessionId.slice('agent-session:'.length) } - await this.publishStructuredAgentSessionTab({ - ...session, - agent: session.agent, - sessionId, - activate: false, - notify: false - }) + return [{ ...session, agent: session.agent, sessionId }] + }) + await seedStructuredAgentSessionTabIndex( + host, + targets, + restored.map((session) => session.sessionId) + ) + // Past the seed, projecting records nothing. + for (const replacement of host?.conversationReplacements?.() ?? []) { + this.replaceStructuredAgentSessionTab(replacement) + } + for (const session of restored) { + this.projectStructuredAgentSessionTab({ ...session, activate: false, notify: false }) } const wasUnverifiable = this.structuredAgentSessionInventoryUnverifiable - // No host means no one can say which chats exist; with none on disk, empty is the answer. + // No host, or one still owed the records file's chats, means no one can say which chats exist; + // with none on disk, empty is the answer. + const importOwed = + typeof host?.legacyRecordImportOwed === 'function' && host.legacyRecordImportOwed() this.structuredAgentSessionInventoryUnverifiable = - !host && this.hasPersistedStructuredAgentSessionStore() + (!host || importOwed) && this.hasPersistedStructuredAgentSessionStore() // This restore published quietly; subscribers still hold the frames that said "cannot tell". if (wasUnverifiable && !this.structuredAgentSessionInventoryUnverifiable) { this.notifyMobileSessionTabSnapshots() @@ -108,12 +120,26 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu }): Promise { const host = getStructuredAgentSessionHost() if (typeof host?.setSessionTabVisibility === 'function') { - await host.setSessionTabVisibility( - input.sessionId, - true, - ...(input.tabId ? [input.tabId] : []) - ) + // The restore index is bookkeeping: one that cannot be written (a newer Orca's records, a + // failing disk) is reported, and the tab still opens. + await host + .setSessionTabVisibility(input.sessionId, true, ...(input.tabId ? [input.tabId] : [])) + .catch((error: unknown) => { + console.warn('[structured-agent-session] recording an opened chat tab failed', error) + }) } + this.projectStructuredAgentSessionTab(input) + } + + /** The runtime's own snapshot of a chat tab. Records nothing: the caller owns the store write. */ + projectStructuredAgentSessionTab(input: { + workspaceId: string + sessionId: string + agent: 'claude' | 'codex' + activate: boolean + notify?: boolean + replacesSessionId?: string + }): void { const existing = this.mobileSessionTabsByWorktree.get(input.workspaceId) const id = `agent-session:${input.sessionId}` if (existing?.tabs.some((tab) => tab.id === id)) { diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index f033cb456ebc..3a16c95a5538 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -348,7 +348,8 @@ export class OrcaRuntimeWithRuntimeId { getPaneAgent: (ptyId) => this.getPaneAgentForTuiIdle(ptyId), getFirstPartyAgentStatus: (ptyId) => (ptyId ? this.ptysById.get(ptyId)?.lastExplicitAgentStatus : null) ?? null, - readScreenLines: (ptyId) => this.readLiveTerminalScreenLines(ptyId) + readScreenLines: (ptyId) => this.readLiveTerminalScreenLines(ptyId), + readScreenRuledLines: (ptyId) => this.readScreenRuledLines(ptyId) } protected readonly terminalIdlePolls = new RuntimeTerminalIdlePolls({ diff --git a/src/main/runtime/orca-runtime-schedule-wait-blocked-check.ts b/src/main/runtime/orca-runtime-schedule-wait-blocked-check.ts index f62192179251..5f874cbc182a 100644 --- a/src/main/runtime/orca-runtime-schedule-wait-blocked-check.ts +++ b/src/main/runtime/orca-runtime-schedule-wait-blocked-check.ts @@ -131,12 +131,26 @@ export class OrcaRuntimeWithScheduleWaitBlockedCheck extends OrcaRuntimeWithOnPt ptyId: string, entry: RuntimePtyTitleTrackerEntry ): void { - if (entry.pendingFacts.length === 0) { + if (entry.pendingFacts.length > 0) { + const facts = entry.pendingFacts + entry.pendingFacts = [] + this.emitTerminalSideEffectBatch(ptyId, facts) + } + const afterFacts = entry.afterFacts + entry.afterFacts = [] + for (const run of afterFacts) { + run() + } + } + + /** Runs `run` after the chunk being applied has emitted its facts, or now between chunks. */ + protected runAfterPendingTerminalSideEffectFacts(ptyId: string, run: () => void): void { + const entry = this.ptyTitleTrackersByPtyId.get(ptyId) + if (entry?.applyingChunk) { + entry.afterFacts.push(run) return } - const facts = entry.pendingFacts - entry.pendingFacts = [] - this.emitTerminalSideEffectBatch(ptyId, facts) + run() } /** Feed a main-fabricated OSC title/BEL frame (agent hook spinners) through diff --git a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts index dd3afbfa23e6..99a3a56943d8 100644 --- a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts +++ b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts @@ -70,7 +70,81 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi return this.ptyForegroundAgent.read(ptyId, afterTitleObservation) } + protected async recheckHookAgentPresenceForPty( + ptyId: string + ): Promise<'live' | 'unverifiable' | 'exited' | null> { + if (!this.checkHookAgentPresenceFn) { + return null + } + const verdicts = await Promise.all( + Array.from(this.collectAgentStatusPaneKeysForPty(ptyId), (paneKey) => + this.checkHookAgentPresenceFn(paneKey) + ) + ) + if (verdicts.includes('live')) { + return 'live' + } + if (verdicts.includes('unverifiable')) { + return 'unverifiable' + } + return verdicts.includes('exited') ? 'exited' : null + } + protected confirmPtyAgentExit(ptyId: string, recoverCompletedHook = false): void { + const current = this.ptysById.get(ptyId) + const incarnation = current?.incarnationId + void this.recheckHookAgentPresenceForPty(ptyId).then((verdict) => { + if (this.ptysById.get(ptyId) !== current || current?.incarnationId !== incarnation) { + return + } + // Why: without an identified owner (null) the foreground read keeps today's rules; with one + // whose process cannot be checked right now, silence from that read is never an exit. + if (verdict === null || verdict === 'unverifiable') { + this.confirmLegacyPtyAgentExit(ptyId, recoverCompletedHook, verdict === 'unverifiable') + } else if (verdict === 'exited' && !recoverCompletedHook) { + this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) + } else if (verdict === 'live') { + this.restoreDisprovedAgentExit(ptyId) + } else { + this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.restoreLastAgentExit() + } + }) + } + + private restoreDisprovedAgentExit(ptyId: string, confirmedStatus?: 'idle'): void { + const current = this.ptysById.get(ptyId) + const restoredStatus = this.ptyTitleTrackersByPtyId + .get(ptyId) + ?.tracker.restoreLastAgentExit(confirmedStatus) + if (!current || restoredStatus === null || restoredStatus === undefined) { + return + } + current.lastAgentStatus = restoredStatus + if (restoredStatus === 'idle') { + this.resolvePtyTuiIdleWaiters(current, ptyId) + } + for (const leaf of this.getLeavesForPty(ptyId)) { + if (leaf.lastAgentStatus !== null) { + continue + } + // Why: the live agent disproved the neutral title's exit signal; keep runtime delivery state aligned with the restored tracker. + leaf.lastAgentStatus = restoredStatus + if (restoredStatus === 'idle') { + this.resolveTuiIdleWaiters(leaf) + // Why gated like every other delivery edge: a neutral-title restoration can + // reinstate `idle` from a name-only title, which is not evidence a turn ended. + if (this.checkDeliverySettledAndArmRecheck(leaf)) { + this.deliverPendingMessagesForLeaf(leaf) + } + } + } + } + + private confirmLegacyPtyAgentExit( + ptyId: string, + recoverCompletedHook: boolean, + keepOnSilence: boolean + ): void { const pty = this.ptysById.get(ptyId) const handle = this.handleByPtyId.get(ptyId) if ( @@ -84,7 +158,9 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi const titleObservedAt = pty?.lastOscTitleAt ?? null const foregroundRead = this.readPtyForegroundProcessFromController(ptyId, titleObservedAt ?? 0) if (!pty?.connected || !foregroundRead) { - if (!recoverCompletedHook) { + if (keepOnSilence) { + this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.restoreLastAgentExit() + } else if (!recoverCompletedHook) { this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) } return @@ -123,34 +199,19 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi recoverCompletedHook && recognizeAgentProcess(result.process)?.agent === 'codex' ? 'idle' : undefined - const restoredStatus = this.ptyTitleTrackersByPtyId - .get(ptyId) - ?.tracker.restoreLastAgentExit(confirmedStatus) - if (restoredStatus !== null && restoredStatus !== undefined) { - current.lastAgentStatus = restoredStatus - if (restoredStatus === 'idle') { - this.resolvePtyTuiIdleWaiters(current, ptyId) - } - for (const leaf of this.getLeavesForPty(ptyId)) { - if (leaf.lastAgentStatus !== null) { - continue - } - // Why: the foreground agent disproved the neutral title's exit signal; keep runtime delivery state aligned with the restored tracker. - leaf.lastAgentStatus = restoredStatus - if (restoredStatus === 'idle') { - this.resolveTuiIdleWaiters(leaf) - // Why gated like every other delivery edge: a neutral-title restoration can - // reinstate `idle` from a name-only title, which is not evidence a turn ended. - if (this.checkDeliverySettledAndArmRecheck(leaf)) { - this.deliverPendingMessagesForLeaf(leaf) - } - } - } - } + this.restoreDisprovedAgentExit(ptyId, confirmedStatus) return } - if (!recoverCompletedHook) { - this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) + const answered = + result.controller === this.ptyController && + result.available && + typeof result.process === 'string' + if (!keepOnSilence || answered) { + if (!recoverCompletedHook) { + this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) + } + } else { + this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.restoreLastAgentExit() } }) } diff --git a/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts b/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts index 4d70572eb2a3..4d74cc89e4af 100644 --- a/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts +++ b/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts @@ -11,8 +11,11 @@ import { import { withTimeout } from './runtime-async-boundaries' import { detectTerminalWaitBlockedReason, + isKnownReadyPromptBody, isKnownReadyPromptSettled } from './terminal-wait-detection' +import { getScreenReadyRule } from './screen-ruled-agent-readiness' +import { restoreProjectedComposerDraft } from './orca-runtime-terminal-projection' import type { RuntimeTerminalWait, RuntimeTerminalWaitBlockedReason @@ -24,7 +27,6 @@ import { buildTerminalWaitResult } from './terminal-wait-results' import { createSetupCompletionScanner } from './orchestration/setup-completion-signal' -import { isAntigravityReadyPromptSnapshot } from './antigravity-terminal-readiness' import type { TuiAgent } from '../../shared/tui-agent' export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWithCreateAgentPromptRenderGate { @@ -53,8 +55,9 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith if (providerTimeoutMs < 1) { return } + const screenRule = getScreenReadyRule(agent) void withTimeout( - this.readTerminal(waiter.handle, agent === 'antigravity' ? { screen: true } : {}, { + this.readTerminal(waiter.handle, screenRule ? { screen: true } : {}, { timeoutMs: providerTimeoutMs, retireOnTimeout: true, // Why: the ready banner stays in scrollback for the whole session, so @@ -72,15 +75,21 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith ) { return } - const snapshotText = - agent === 'antigravity' - ? [...projection.tail, projection.draft ?? ''].join('\n') - : projection.tail.join('\n') + const snapshotText = projection.tail.join('\n') const blockedReason = detectTerminalWaitBlockedReason(snapshotText) - const ready = - agent === 'antigravity' - ? isAntigravityReadyPromptSnapshot(snapshotText) - : isKnownReadyPromptSettled(snapshotText) + // Why the shared tier-1 rule: a probe must not settle what the live screen would refuse. + const ready = screenRule + ? isKnownReadyPromptBody( + snapshotText, + agent, + () => restoreProjectedComposerDraft(projection.tail, projection.draft), + // Why read now: output since the probe started makes the pane clocked. + ( + this.getLivePtyForHandle(waiter.handle)?.pty ?? + this.getLiveLeafForHandle(waiter.handle).leaf + ).lastOutputAt !== null + ) + : isKnownReadyPromptSettled(snapshotText) if (!blockedReason && !ready) { return } diff --git a/src/main/runtime/orca-runtime-state-fields.ts b/src/main/runtime/orca-runtime-state-fields.ts index ef54b73252c7..ca95bf9dc270 100644 --- a/src/main/runtime/orca-runtime-state-fields.ts +++ b/src/main/runtime/orca-runtime-state-fields.ts @@ -82,6 +82,9 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { terminalProvenance: 'current_runtime' | 'restored' }) => AgentHookAuthorityAttestation | null retireAgentHookCompatibilityAuthority?: (paneKey: string) => void + checkHookAgentPresence?: ( + paneKey: string + ) => Promise<'live' | 'unverifiable' | 'exited' | null> reconcileAgentStatusForEndedProcess?: (paneKeys: Iterable) => void canRecoverPersistentLocalPtys?: () => boolean // Why: the device registry lives on the RPC server, which is constructed with this runtime; @@ -230,6 +233,7 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { deps?.attestAgentHookCompatibilityAuthority ?? null this.retireAgentHookCompatibilityAuthorityFn = deps?.retireAgentHookCompatibilityAuthority ?? null + this.checkHookAgentPresenceFn = deps?.checkHookAgentPresence ?? null this.reconcileAgentStatusForEndedProcessFn = deps?.reconcileAgentStatusForEndedProcess ?? null this.canRecoverPersistentLocalPtysFn = deps?.canRecoverPersistentLocalPtys ?? (() => true) this.getPairedDeviceNameFn = deps?.getPairedDeviceName ?? (() => null) diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index a7175383df0d..1e54583435c6 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -24,6 +24,9 @@ import { RuntimeAgentOrchestrationProjection } from './runtime-agent-orchestrati import { RuntimeTerminalList } from './runtime-terminal-list' import { RuntimeManagedWorktreeQueries } from './runtime-managed-worktree-queries' import { RuntimePtyForegroundAgent } from './runtime-pty-foreground-agent' +import { OpenCodeRunLifetimeStatus } from './opencode-run-lifetime-status' +import { readLocalPtyForegroundCommandLine } from './local-pty-foreground-command-line' +import { isAgentStatusHooksEnabledForAgent } from '../../shared/agent-status-hooks-setting' import { RuntimeTerminalAgentStatusQuery } from './runtime-terminal-agent-status-query' import type { OrchestrationDb } from './orchestration/db' import { OrchestrationMailboxOwner } from './orchestration/mailbox-owner' @@ -162,6 +165,33 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId } }) + protected readonly openCodeRunLifetime = new OpenCodeRunLifetimeStatus({ + isObservablePty: (ptyId) => { + const pty = this.ptysById.get(ptyId) + // Why: SSH and WSL foregrounds live on another host or in the guest. + return !!pty && !pty.connectionId && !pty.wslDistro && !this.wslDistroByPtyId.has(ptyId) + }, + isStatusEnabled: (agent) => + isAgentStatusHooksEnabledForAgent(this.store?.getSettings?.(), agent), + readForegroundProcessName: async (ptyId) => { + const read = await this.ptyForegroundAgent.read(ptyId) + return read?.available ? read.process : null + }, + readForegroundCommandLine: (ptyId, foregroundProcess) => + readLocalPtyForegroundCommandLine(ptyId, foregroundProcess), + // Why after the chunk's facts: the renderer drops an exited agent's row on command-finished + // unless the row changed after it, so the run's Done must arrive after that fact. + publish: (ptyId, payload, yieldsToHookSince) => + this.runAfterPendingTerminalSideEffectFacts(ptyId, () => + this.emitTerminalAgentStatusEvents( + ptyId, + { payloads: [payload] }, + { origin: 'process', yieldsToHookSince } + ) + ), + now: () => Date.now() + }) + protected readonly terminalAgentStatus = new RuntimeTerminalAgentStatusQuery({ getController: () => this.ptyController, getLivePty: (handle) => this.getLivePtyForHandle(handle), diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index 1d8105d07f81..92c39dda2835 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -308,7 +308,8 @@ describe('structured session cold restoration', () => { }) expect(closeSessionTab).toHaveBeenCalledWith('agent-session:restored-session', 'workspace-1') - expect(closeStructuredSession).toHaveBeenCalledWith('restored-session') + // The user closed this chat, so a turn the close cuts short is their cancellation. + expect(closeStructuredSession).toHaveBeenCalledWith('restored-session', 'user-close') expect(setSessionTabVisibility).toHaveBeenCalledWith('restored-session', false) expect(setSessionTabVisibility.mock.invocationCallOrder[0]).toBeLessThan( closeStructuredSession.mock.invocationCallOrder[0]! @@ -324,7 +325,7 @@ describe('structured session cold restoration', () => { it('publishes restored Claude tabs with the Claude title', async () => { const runtime = new OrcaRuntimeService() - const publish = vi.spyOn(runtime, 'publishStructuredAgentSessionTab') + const project = vi.spyOn(runtime, 'projectStructuredAgentSessionTab') const internal = runtime as unknown as { hasPersistedStructuredAgentSessionStore(): boolean getKnownWorkspaceSessionWorktreeIds(): Set @@ -351,7 +352,7 @@ describe('structured session cold restoration', () => { await runtime.restoreStructuredAgentSessionTabs() - expect(publish).toHaveBeenCalledWith({ + expect(project).toHaveBeenCalledWith({ workspaceId: 'workspace-1', sessionId: 'restored-claude', agent: 'claude', @@ -372,6 +373,30 @@ describe('structured session cold restoration', () => { ) }) + // The /clear commit moved the tab in the store already, so replacing it only projects. + it('replaces a cleared conversation tab without a store write', async () => { + const runtime = new OrcaRuntimeService() + const setSessionTabVisibility = vi.fn(async () => undefined) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: replacing a tab reaches the host only through setSessionTabVisibility, which must stay uncalled. + setStructuredAgentSessionHost({ setSessionTabVisibility } as never) + + runtime.replaceStructuredAgentSessionTab({ + sourceSessionId: 'cleared-session', + sessionId: 'replacement-session', + workspaceId: 'workspace-1', + agent: 'codex' + }) + + const snapshot = await runtime.listMobileSessionTabs('id:workspace-1') + expect(snapshot.tabs).toEqual([ + expect.objectContaining({ + id: 'agent-session:replacement-session', + replacesSessionId: 'cleared-session' + }) + ]) + expect(setSessionTabVisibility).not.toHaveBeenCalled() + }) + it('commits the host close when the renderer already removed the structured tab', async () => { const runtime = new OrcaRuntimeService() runtime.setNotifier({ diff --git a/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts b/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts index 8b048f168d49..b931af574632 100644 --- a/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts +++ b/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts @@ -69,6 +69,10 @@ describe('every host that constructs a runtime wires the agent-status store', () // A sink without it leaves the host holding no child records for that entry point. expect(construction).toContain('publishChildWork: (subject, evidence, provider) =>') expect(construction).toContain('ingestStructuredChildWork(subject, evidence, provider)') + // Without it the summary and the chat strip read no child records on that entry point. + expect(construction).toContain( + 'readChildWork: (subject) => agentHookServer.getStructuredChildWorkViews(subject)' + ) } ) }) diff --git a/src/main/runtime/orca-runtime-terminal-projection.ts b/src/main/runtime/orca-runtime-terminal-projection.ts index 7bd45bbfb51d..b706a0420aad 100644 --- a/src/main/runtime/orca-runtime-terminal-projection.ts +++ b/src/main/runtime/orca-runtime-terminal-projection.ts @@ -41,3 +41,23 @@ export function projectTerminalVisibleLines(emulator: HeadlessEmulator): { ...(draft ? { draft: draft.text } : {}) } } + +const PROJECTED_PROMPT_GLYPHS: ReadonlySet = new Set(['❯', '›', '»']) + +/** Undoes the projection's draft blanking, for rules that read the rows the TUI painted. */ +export function restoreProjectedComposerDraft( + lines: readonly string[], + draft: string | undefined +): string[] { + const promptRow = draft ? lines.findLastIndex((line) => PROJECTED_PROMPT_GLYPHS.has(line)) : -1 + if (draft === undefined || promptRow === -1) { + return [...lines] + } + const [first = '', ...rest] = draft.split('\n') + return [ + ...lines.slice(0, promptRow), + `${lines[promptRow]} ${first}`, + ...rest, + ...lines.slice(promptRow + 1) + ] +} diff --git a/src/main/runtime/orca-runtime-test-lifecycle.spec.ts b/src/main/runtime/orca-runtime-test-lifecycle.spec.ts index e815599728b2..413035af5094 100644 --- a/src/main/runtime/orca-runtime-test-lifecycle.spec.ts +++ b/src/main/runtime/orca-runtime-test-lifecycle.spec.ts @@ -1,4 +1,6 @@ import * as mocks from './orca-runtime-test-mocks.spec' +import { awaitBackgroundRemovalsInRuntimeTests } from './orca-runtime-background-removal-test-support' +import { _resetPendingWorktreeRemovalsForTests } from '../worktree-background-removal' const { MOCK_GIT_WORKTREES, RuntimeBrowserCommands, _resetTerminalViewAttributesForTest } = mocks const { addGitHubIssueCommentMock, addGitHubPRReviewCommentMock } = mocks @@ -57,7 +59,11 @@ const { sshProviderGenerations, unregisterSshGitProviderMock, updateGitHubIssueM const { updateGitHubPRDetailsMock, updateGitHubPRStateMock, updateGitHubPRTitleMock } = mocks const { updateGitLabIssueMock, updateGitLabMRMock, updateGitLabMRReviewersMock, vi } = mocks +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the prototype carries the removal method the wrapper replaces. +awaitBackgroundRemovalsInRuntimeTests(mocks.OrcaRuntimeService.prototype as never) + function resetRuntimeTestMocks(): void { + _resetPendingWorktreeRemovalsForTests() // Why: constructing the browser commands is what pulls the Chromium cluster in, so // production installs this at the Electron entry. A Node host installs none and the // browser RPCs reject rather than silently succeeding. diff --git a/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts b/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts index 1d63e09c4c7f..d0cd3608414d 100644 --- a/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts +++ b/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts @@ -437,13 +437,16 @@ async function reconcileWithToken(runtime: RuntimeService, token: string): Promi fetchPromise: Promise.resolve({ ok: true }) }) } -function createSideEffectRuntime(): { +function createSideEffectRuntime( + checkHookAgentPresence?: (paneKey: string) => Promise<'live' | 'unverifiable' | 'exited' | null> +): { runtime: RuntimeService batches: TerminalSideEffectBatch[] } { const batches: TerminalSideEffectBatch[] = [] const runtime = new OrcaRuntimeService(store, undefined, { - onTerminalSideEffects: (batch) => batches.push(batch) + onTerminalSideEffects: (batch) => batches.push(batch), + checkHookAgentPresence }) return { runtime, batches } } diff --git a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts index 79feca3268c3..a65181043d10 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts @@ -474,27 +474,77 @@ describe('terminal side-effect fact channel', () => { ) }) - it('treats synchronous foreground read failures as unavailable', async () => { - const { runtime, batches } = createSideEffectRuntime() + it.each([ + { presence: 'unverifiable' as const, exited: true }, + { presence: 'exited' as const, exited: true }, + { presence: 'live' as const, exited: false } + ])('confirms an exit title from hook presence $presence', async ({ presence, exited }) => { + const checkPresence = vi.fn(async () => presence) + const { runtime, batches } = createSideEffectRuntime(checkPresence) syncSinglePty(runtime) + const getForegroundProcess = vi.fn(async () => 'zsh') + runtime.setPtyController({ write: () => true, kill: () => true, getForegroundProcess }) runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') - const getForegroundProcess = vi.fn(() => { - throw new TypeError('getForegroundProcess is unavailable') - }) - runtime.setPtyController({ - write: () => true, - kill: () => true, - getForegroundProcess - }) - runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;~/repo\x07', 100) - await vi.waitFor(() => - expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ kind: 'agent-exited' }) + await vi.waitFor(() => expect(checkPresence).toHaveBeenCalled()) + await new Promise((resolve) => setTimeout(resolve, 50)) + // Why: an unanswered process check (Codex, SSH, Windows) must still reach the foreground read. + expect(batches.flatMap((batch) => batch.facts).some((f) => f.kind === 'agent-exited')).toBe( + exited ) - expect(getForegroundProcess).toHaveBeenCalledOnce() }) + it.each(['wsl.exe', 'tmux'])( + 'confirms an exit when the foreground answers with non-agent %s', + async (foreground) => { + const { runtime, batches } = createSideEffectRuntime(async () => null) + syncSinglePty(runtime) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: vi.fn(async () => foreground) + }) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') + + runtime.onPtyData('pty-1', '\x1b]0;~/repo\x07', 100) + + await vi.waitFor(() => + expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ kind: 'agent-exited' }) + ) + } + ) + + it.each([ + { presence: null, exited: true }, + { presence: 'unverifiable' as const, exited: false } + ])( + 'treats synchronous foreground read failures as unavailable (hook presence $presence)', + async ({ presence, exited }) => { + // Why: only a pane with an identified owner keeps its agent on silence; others keep today's exit. + const { runtime, batches } = createSideEffectRuntime(async () => presence) + syncSinglePty(runtime) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') + const getForegroundProcess = vi.fn(() => { + throw new TypeError('getForegroundProcess is unavailable') + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess + }) + + runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 100) + + await vi.waitFor(() => expect(getForegroundProcess).toHaveBeenCalled()) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect( + batches.flatMap((batch) => batch.facts).some((fact) => fact.kind === 'agent-exited') + ).toBe(exited) + } + ) + it('aligns a restored session and pre-response bytes to the provider sequence', async () => { const { runtime } = createSideEffectRuntime() diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts index a9edac0ede7a..96abf6a97cef 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts @@ -1,5 +1,6 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' import { resetWorktreeTestSshHostHome } from '../../worktree-removal-test-ssh-host-home' +import { _settlePendingWorktreeRemovalsForTests } from '../../worktree-background-removal' import { OrcaRuntimeService, @@ -273,7 +274,7 @@ describe('OrcaRuntimeService', () => { } }) - it('rejects concurrent runtime worktree removals for the same id with different options', async () => { + it('joins a runtime removal Git is already deleting, even with different options', async () => { const runtime = createWorktreeRemovalRuntime() const removeStarted = deferred() const finishRemoval = deferred() @@ -283,16 +284,51 @@ describe('OrcaRuntimeService', () => { return {} }) + const resolveTarget: MockInstance<(...args: unknown[]) => Promise> = vi.spyOn( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: resolveWorktreeRemovalTarget is a protected runtime method the spy only observes. + runtime as never, + 'resolveWorktreeRemovalTarget' + ) const first = runtime.removeManagedWorktree(TEST_WORKTREE_ID) await removeStarted.promise - await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true })).rejects.toThrow( - 'Worktree deletion already in progress' - ) - + // Accepted means every refusal already ran; a Force Delete retry has nothing left to waive. + const second = runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true }) + await vi.waitFor(() => expect(resolveTarget).toHaveBeenCalledTimes(2)) + // The retry checks the removal table right after this resolves, before Git is let finish. + await resolveTarget.mock.results[1]?.value + finishRemoval.resolve() + await expect(Promise.all([first, second])).resolves.toEqual([{}, {}]) expect(removeWorktree).toHaveBeenCalledTimes(1) + }) + + it('answers a client that cannot wait as soon as Git starts deleting', async () => { + const runtime = createWorktreeRemovalRuntime() + const removeStarted = deferred() + const finishRemoval = deferred() + vi.mocked(removeWorktree).mockImplementation(async () => { + removeStarted.resolve() + await finishRemoval.promise + return {} + }) + + const accepted = runtime.removeManagedWorktree(TEST_WORKTREE_ID, { + waitForBackgroundRemoval: false + }) + await removeStarted.promise + await expect(accepted).resolves.toEqual({ removing: true }) finishRemoval.resolve() - await expect(first).resolves.toEqual({}) + await _settlePendingWorktreeRemovalsForTests() + }) + + it('replies to a waiting client with the error of a delete that fails after acceptance', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const runtime = createWorktreeRemovalRuntime() + vi.mocked(removeWorktree).mockRejectedValue(new Error('permission denied')) + + await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID)).rejects.toThrow( + 'permission denied' + ) }) it('treats forced runtime deletion of an already-missing unregistered worktree as cleanup', async () => { diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-archive-hook-gate.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-archive-hook-gate.spec.ts index 1333d6bd469d..ee5d67745e64 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-archive-hook-gate.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-archive-hook-gate.spec.ts @@ -23,6 +23,9 @@ import { ARCHIVE_HOOK_FAILED_REMOVAL_CODE, asArchiveHookRefusal } from '../../../shared/worktree/archive-hook-removal-gate' +import { runSerializedWorktreeRemovalAcceptance } from '../../worktree-removal-acceptance-queue' +import { startBackgroundWorktreeRemoval } from '../../worktree-background-removal' +import type { RemoveWorktreeResult } from '../../../shared/worktree/create-types' function withArchiveHook(): void { vi.mocked(getEffectiveHooks).mockReturnValue({ @@ -206,6 +209,67 @@ describe('archive hook removal gate', () => { expect(removeWorktree).toHaveBeenCalled() }) + it('holds a desktop removal in the same repo until this one is accepted', async () => { + const runtime = createWorktreeRemovalRuntime() + withArchiveHook() + const hookRun = deferred<{ success: boolean; output: string; exitCode?: number }>() + vi.mocked(runHook).mockReturnValue(hookRun.promise) + vi.mocked(removeWorktree).mockResolvedValue({}) + + const removal = runtime.removeManagedWorktree(TEST_WORKTREE_ID, { + force: false, + runHooks: true + }) + await vi.waitFor(() => expect(runHook).toHaveBeenCalled()) + // The desktop IPC path queues its acceptance on this same per-repo queue. + const sibling = vi.fn(async () => {}) + const siblingAccepted = runSerializedWorktreeRemovalAcceptance(TEST_REPO_PATH, sibling) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(sibling).not.toHaveBeenCalled() + + hookRun.resolve({ success: true, output: '', exitCode: 0 }) + await siblingAccepted + expect(sibling).toHaveBeenCalledTimes(1) + await expect(removal).resolves.toEqual({}) + }) + + it('joins a desktop removal of the same worktree accepted while this one waited', async () => { + const runtime = createWorktreeRemovalRuntime() + withArchiveHook() + vi.mocked(runHook).mockResolvedValue({ success: true, output: '' }) + vi.mocked(removeWorktree).mockResolvedValue({}) + const siblingTurn = deferred() + void runSerializedWorktreeRemovalAcceptance(TEST_REPO_PATH, () => siblingTurn.promise) + + const removal = runtime.removeManagedWorktree(TEST_WORKTREE_ID, { + force: false, + runHooks: true + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + // The desktop IPC path coalesces apart from this one; stand in for its accepted removal. + const desktopDelete = deferred() + void startBackgroundWorktreeRemoval({ + removal: { + worktreeId: TEST_WORKTREE_ID, + repoId: 'repo-1', + repoPath: TEST_REPO_PATH, + worktree: { path: TEST_WORKTREE_PATH, branch: 'feature', head: 'feature' }, + deleteBranch: true, + force: false + }, + run: () => desktopDelete.promise, + publish: () => {} + }) + siblingTurn.resolve() + await new Promise((resolve) => setTimeout(resolve, 20)) + const preserved = { preservedBranch: { branchName: 'feature', head: 'feature' } } + desktopDelete.resolve(preserved) + + await expect(removal).resolves.toMatchObject(preserved) + expect(runHook).not.toHaveBeenCalled() + expect(removeWorktree).not.toHaveBeenCalled() + }) + it('does not coalesce an override retry onto the refusal already in flight', async () => { const runtime = createWorktreeRemovalRuntime() withArchiveHook() diff --git a/src/main/runtime/orca-runtime-visible-snapshot-preview.ts b/src/main/runtime/orca-runtime-visible-snapshot-preview.ts index be14a92c3193..64cda7b73f2c 100644 --- a/src/main/runtime/orca-runtime-visible-snapshot-preview.ts +++ b/src/main/runtime/orca-runtime-visible-snapshot-preview.ts @@ -2,12 +2,16 @@ import { OrcaRuntimeWithCaptureProviderTerminalBuffer } from './orca-runtime-capture-provider-terminal-buffer' import type { RuntimeTerminalProjection } from './orca-runtime-core' import { buildPreview } from './terminal-tail-state' -import type { RuntimeVisibleTerminalState } from './runtime-terminal-state-records' +import type { + RuntimeHeadlessTerminal, + RuntimeVisibleTerminalState +} from './runtime-terminal-state-records' import { VISIBLE_TERMINAL_SNAPSHOT_RETRY_MS, VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS } from './orca-runtime-postlude' import { projectTerminalVisibleLines } from './orca-runtime-terminal-projection' +import { visibleNonBlankTerminalLines } from './terminal-tail-read' import { HeadlessEmulator } from '../daemon/headless-emulator' import { withTimeout } from './runtime-async-boundaries' @@ -143,6 +147,29 @@ export class OrcaRuntimeWithVisibleSnapshotPreview extends OrcaRuntimeWithCaptur /** Synchronous visible grid of the live emulator, for tui-idle body evidence. Null when the * model is not the whole screen: a provider-restored partial suffix or a pending hydration. */ protected readLiveTerminalScreenLines(ptyId: string | null | undefined): string[] | null { + const state = this.readWholeScreenModel(ptyId) + // Why unawaited writeChain: callers are synchronous; a grid one chunk behind is re-read next poll. + return state ? projectTerminalVisibleLines(state.emulator).lines : null + } + + /** The rows a screen-ruled agent's rule reads: as painted, and only on the PTY's own grid. */ + protected readScreenRuledLines(ptyId: string | null | undefined): string[] | null { + const state = this.readWholeScreenModel(ptyId) + if (!ptyId || !state || state.unrepaintedReflowGrid !== undefined) { + return null + } + // Why: a TUI painted for another grid garbles on this one, and a screen rule would refuse it. + const ptySize = this.getTerminalSize(ptyId) + const grid = state.emulator.getAppliedSize() + if (!ptySize || ptySize.cols !== grid.cols || ptySize.rows !== grid.rows) { + return null + } + // Why raw rows, not the read projection: it blanks a composer it takes for a draft, and Cline's + // placeholder reads as one, so a typed draft and an empty composer would look the same. + return visibleNonBlankTerminalLines(state.emulator.getVisibleLines()) + } + + protected readWholeScreenModel(ptyId: string | null | undefined): RuntimeHeadlessTerminal | null { if (!ptyId) { return null } @@ -154,8 +181,7 @@ export class OrcaRuntimeWithVisibleSnapshotPreview extends OrcaRuntimeWithCaptur ) { return null } - // Why unawaited writeChain: callers are synchronous; a grid one chunk behind is re-read next poll. - return projectTerminalVisibleLines(state.emulator).lines + return state } protected async parseVisibleSnapshot(snapshot: { diff --git a/src/main/runtime/orchestration-codex-completion-title.test.ts b/src/main/runtime/orchestration-codex-completion-title.test.ts index 92f55d166ff4..70de0c987fed 100644 --- a/src/main/runtime/orchestration-codex-completion-title.test.ts +++ b/src/main/runtime/orchestration-codex-completion-title.test.ts @@ -28,7 +28,7 @@ vi.mock('electron', () => ({ webContents: { fromId: vi.fn(() => null) } })) -function completionFixture(delayMs = 0) { +function completionFixture(delayMs = 0, presence?: 'unverifiable') { const db = createDatabase('orca-codex-completion-title-') const hook: AgentStatusIpcPayload = { paneKey: PANE_KEY, @@ -40,7 +40,11 @@ function completionFixture(delayMs = 0) { receivedAt: Date.now(), stateStartedAt: Date.now() } - const { runtime } = createRuntime(db, { getAgentStatusSnapshot: () => [hook] }) + const { runtime } = createRuntime(db, { + getAgentStatusSnapshot: () => [hook], + // Why: a Codex hook row has presence but no process id, so the store cannot answer. + ...(presence ? { checkHookAgentPresence: async () => presence } : {}) + }) const write = vi.fn((_ptyId: string, _data: string) => true) const getForegroundProcess = vi.fn(async (): Promise => { if (delayMs) { @@ -75,12 +79,17 @@ describe('Codex completion title mailbox delivery', () => { { arrival: 'before', delay: 0 }, { arrival: 'after', delay: 0 }, { arrival: 'before', delay: 750 }, - { arrival: 'after', delay: 750 } + { arrival: 'after', delay: 750 }, + { arrival: 'before', delay: 0, presence: 'unverifiable' as const }, + { arrival: 'after', delay: 750, presence: 'unverifiable' as const } ])( - 'submits mail arriving $arrival completion with a $delay ms host probe', - async ({ arrival, delay }) => { + 'submits mail arriving $arrival completion with a $delay ms host probe (presence $presence)', + async ({ arrival, delay, presence }) => { vi.useFakeTimers() - const { db, runtime, write, run, completeWithNativeTitles } = completionFixture(delay) + const { db, runtime, write, run, completeWithNativeTitles } = completionFixture( + delay, + presence + ) await runtime.listTerminals() if (arrival === 'before') { insertDirectRunMessage(db, run.id, 'Worker progress') diff --git a/src/main/runtime/orchestration-live-agent-title-restore.test.ts b/src/main/runtime/orchestration-live-agent-title-restore.test.ts new file mode 100644 index 000000000000..a22db7c0d63e --- /dev/null +++ b/src/main/runtime/orchestration-live-agent-title-restore.test.ts @@ -0,0 +1,75 @@ +import { rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import { settledWriteStub } from '../providers/settled-pty-write-stub' +import { + createBoundRun, + createDatabase, + createRuntime, + insertDirectRunMessage, + PANE_KEY, + PTY_ID, + TERMINAL_HANDLE, + temporaryDirectories +} from './orchestration-mailbox-notification-test-harness' + +vi.mock('electron', () => ({ + app: { getPath: vi.fn(() => tmpdir()), isPackaged: false }, + BrowserWindow: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + webContents: { fromId: vi.fn(() => null) } +})) + +describe('neutral title while the hook-owned agent process is alive', () => { + afterEach(() => { + vi.useRealTimers() + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + }) + + it('restores the idle agent and delivers mail without a foreground read', async () => { + vi.useFakeTimers() + const db = createDatabase('orca-live-agent-title-restore-') + const hook: AgentStatusIpcPayload = { + paneKey: PANE_KEY, + terminalHandle: TERMINAL_HANDLE, + agentType: 'claude', + state: 'done', + prompt: '', + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: Date.now() + } + const { runtime } = createRuntime(db, { + getAgentStatusSnapshot: () => [hook], + checkHookAgentPresence: async () => 'live' + }) + const write = vi.fn((_ptyId: string, _data: string) => true) + const getForegroundProcess = vi.fn(async () => 'claude') + runtime.setPtyController({ + write, + writeWithSettlement: settledWriteStub(write), + kill: vi.fn(), + getForegroundProcess + }) + const run = createBoundRun(db, 'Claude Run') + await runtime.listTerminals() + + runtime.onPtyData(PTY_ID, '\x1b]0;✳ Claude Code\x07', 1) + await vi.advanceTimersByTimeAsync(50) + runtime.onPtyData(PTY_ID, '\x1b]0;some-tool-title\x07', 2) + await vi.advanceTimersByTimeAsync(200) + insertDirectRunMessage(db, run.id, 'Worker progress') + runtime.notifyMessageArrived(`run:${run.id}`, 'status') + await vi.advanceTimersByTimeAsync(3000) + + expect(getForegroundProcess).not.toHaveBeenCalled() + expect(write.mock.calls.map(([, data]) => data)).toEqual([ + expect.stringContaining('You have 1 orchestration message'), + '\r' + ]) + db.close() + }) +}) diff --git a/src/main/runtime/orchestration-mailbox-notification-test-harness.ts b/src/main/runtime/orchestration-mailbox-notification-test-harness.ts index 7bd3ae4665ee..2622416d74f0 100644 --- a/src/main/runtime/orchestration-mailbox-notification-test-harness.ts +++ b/src/main/runtime/orchestration-mailbox-notification-test-harness.ts @@ -89,10 +89,12 @@ export function createRuntime( connectionId?: string isWsl?: boolean getAgentStatusSnapshot?: () => AgentStatusIpcPayload[] + checkHookAgentPresence?: (paneKey: string) => Promise<'live' | 'unverifiable' | 'exited' | null> } = {} ): MailboxNotificationHarness { const runtime = new OrcaRuntimeService(null, undefined, { getAgentStatusSnapshot: options.getAgentStatusSnapshot, + checkHookAgentPresence: options.checkHookAgentPresence, attestAgentHookCompatibilityAuthority: ({ paneKey }) => paneKey === PANE_KEY || paneKey.startsWith(`${SECOND_TAB_ID}:`) ? { paneKey, source: 'current_hook' } diff --git a/src/main/runtime/orchestration/coordinator-dispatch-unobserved-prompt.test.ts b/src/main/runtime/orchestration/coordinator-dispatch-unobserved-prompt.test.ts index f09608039880..579828337a07 100644 --- a/src/main/runtime/orchestration/coordinator-dispatch-unobserved-prompt.test.ts +++ b/src/main/runtime/orchestration/coordinator-dispatch-unobserved-prompt.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { OrchestrationDb } from './db' import type { CoordinatorRuntime } from './coordinator-runtime-contract' import { dispatchTaskToWorker } from './coordinator-task-dispatch' +import { reattachDispatchConsumer } from './db/root-dispatch-test-fixture' const WORKER_PANE_KEY = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' let db: OrchestrationDb @@ -91,20 +92,12 @@ describe('coordinator dispatch with an unobserved prompt', () => { const task = db.createTask({ runId: 'run_legacy_local', spec: 'do the work' }) await dispatch(createRuntime(new Error('agent_prompt_stalled')), task.id, []) const dispatchId = db.getDispatchContext(task.id)!.id - const minted = db.mintDispatchCapability({ + reattachDispatchConsumer(db, { dispatchId, paneKey: WORKER_PANE_KEY, processIncarnation: 'incarnation-1' }) - expect( - db.verifyDispatchCapability({ - dispatchId, - capability: minted, - paneKey: WORKER_PANE_KEY, - processIncarnation: 'incarnation-1' - }) - ).toEqual({ valid: true }) expect( db.settleWorkerReport({ taskId: task.id, diff --git a/src/main/runtime/orchestration/db-stopping-worker-task-guard.test.ts b/src/main/runtime/orchestration/db-stopping-worker-task-guard.test.ts index a747ae07a510..01e238e51b51 100644 --- a/src/main/runtime/orchestration/db-stopping-worker-task-guard.test.ts +++ b/src/main/runtime/orchestration/db-stopping-worker-task-guard.test.ts @@ -93,7 +93,9 @@ describe('a Task whose supervised worker is stopping', () => { expect(db.getWorkerDispatch(dispatch.id)?.runtime_epoch).toBe('epoch_new_runtime') db.markWorkerStopUnknown(dispatch.id, 'the execution host did not answer') - expect(db.abandonWorkerDispatch(dispatch.id)).toMatchObject({ disposition: 'abandoned' }) + expect(db.abandonWorkerDispatch(dispatch.id, 'epoch_test')).toMatchObject({ + disposition: 'abandoned' + }) expect(db.getTask(task.id)?.status).toBe('blocked') }) diff --git a/src/main/runtime/orchestration/db-task-dispatch-invariant.test.ts b/src/main/runtime/orchestration/db-task-dispatch-invariant.test.ts index 2b81b2f2897c..ca5f890186ad 100644 --- a/src/main/runtime/orchestration/db-task-dispatch-invariant.test.ts +++ b/src/main/runtime/orchestration/db-task-dispatch-invariant.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type Database from '../../sqlite/sync-database' import { OrchestrationDb } from './db' -import { createRootDispatch } from './db/root-dispatch-test-fixture' +import { createRootDispatch, reattachDispatchConsumer } from './db/root-dispatch-test-fixture' type DatabaseHarness = { db: OrchestrationDb @@ -82,7 +82,7 @@ describe('Task/Dispatch invariant transactions', () => { deps: [task.id] }) const dispatch = createRootDispatch(db, task.id, 'term_worker') - const capability = db.mintDispatchCapability({ + reattachDispatchConsumer(db, { dispatchId: dispatch.id, paneKey: 'tab_worker:leaf_worker', processIncarnation: 'worker:1' @@ -109,14 +109,6 @@ describe('Task/Dispatch invariant transactions', () => { completed_at: null, capability_revoked_at: null }) - expect( - db.verifyDispatchCapability({ - dispatchId: dispatch.id, - capability, - paneKey: 'tab_worker:leaf_worker', - processIncarnation: 'worker:1' - }) - ).toEqual({ valid: true }) expect(db.getTask(dependent.id)?.status).toBe('pending') } ) @@ -415,7 +407,7 @@ describe('Task/Dispatch invariant transactions', () => { taskId: task.id, startOptions: {} }) - const capability = db.prepareStartingWorkerAuthority({ + db.prepareStartingWorkerAuthority({ dispatchId: started.dispatch.id, handle: 'term_worker', paneKey: 'tab_worker:dddddddd-dddd-4ddd-8ddd-dddddddddddd', @@ -443,14 +435,6 @@ describe('Task/Dispatch invariant transactions', () => { capability_revoked_at: null }) expect(db.getWorkerDispatch(started.dispatch.id)?.state).toBe('starting') - expect( - db.verifyDispatchCapability({ - dispatchId: started.dispatch.id, - capability, - paneKey: 'tab_worker:dddddddd-dddd-4ddd-8ddd-dddddddddddd', - processIncarnation: 'worker:1' - }) - ).toEqual({ valid: true }) } ) diff --git a/src/main/runtime/orchestration/db-task-dispatch-lifecycle-guards.test.ts b/src/main/runtime/orchestration/db-task-dispatch-lifecycle-guards.test.ts index f4cdcdf63b83..88445b95bac2 100644 --- a/src/main/runtime/orchestration/db-task-dispatch-lifecycle-guards.test.ts +++ b/src/main/runtime/orchestration/db-task-dispatch-lifecycle-guards.test.ts @@ -8,7 +8,6 @@ import { createRootDispatch } from './db/root-dispatch-test-fixture' type WorkerFixture = { dispatchId: string - capability: string handle: string paneKey: string processIncarnation: string @@ -47,8 +46,6 @@ describe('Task/Dispatch lifecycle guards', () => { expect(database.getDispatchContextById(second.dispatchId)?.status).toBe('dispatched') expect(database.getWorkerDispatch(first.dispatchId)?.state).toBe('ready') expect(database.getWorkerDispatch(second.dispatchId)?.state).toBe('ready') - expectCapability(database, first, true) - expectCapability(database, second, true) }) it.each(['succeeded', 'failed'] as const)( @@ -115,7 +112,7 @@ describe('Task/Dispatch lifecycle guards', () => { }) it.each(['failed', 'stopped'] as const)( - 'treats abandon of an already %s worker as stale without a lifecycle conflict', + 'treats abandon of an already %s worker as settled without a lifecycle conflict', (state) => { const database = createDatabase() const task = database.createTask({ @@ -130,8 +127,8 @@ describe('Task/Dispatch lifecycle guards', () => { database.settleWorkerStop(worker.dispatchId) } - expect(database.abandonWorkerDispatch(worker.dispatchId)).toMatchObject({ - disposition: 'stale', + expect(database.abandonWorkerDispatch(worker.dispatchId, 'epoch_test')).toMatchObject({ + disposition: 'already_settled', worker: { state } }) } @@ -158,7 +155,6 @@ describe('Task/Dispatch lifecycle guards', () => { capability_revoked_at: null }) expect(database.getWorkerDispatch(worker.dispatchId)?.state).toBe('ready') - expectCapability(database, worker, true) }) it('atomically settles worker state when a proven process exit fails its Dispatch', () => { @@ -178,7 +174,6 @@ describe('Task/Dispatch lifecycle guards', () => { stage: 'process_exited', last_error: 'process exited' }) - expectCapability(database, worker, false) }) it('settles a stop-unknown worker when a positive PTY exit arrives', () => { @@ -211,7 +206,6 @@ describe('Task/Dispatch lifecycle guards', () => { stage: 'process_exited', last_error: 'process exited' }) - expectCapability(database, worker, false) }) it('keeps a Task dispatched when missing-terminal recovery leaves another worker active', () => { @@ -234,12 +228,10 @@ describe('Task/Dispatch lifecycle guards', () => { expect(database.getWorkerDispatch(missing.dispatchId)?.state).toBe('abandoned') expect(database.getDispatchContextById(live.dispatchId)?.status).toBe('dispatched') expect(database.getWorkerDispatch(live.dispatchId)?.state).toBe('ready') - expectCapability(database, missing, false) - expectCapability(database, live, true) database.reconcileMissingWorkerTerminal(live.dispatchId, 'second terminal missing') expect(database.getTask(task.id)?.status).toBe('ready') - expectCapability(database, live, false) + expect(database.getDispatchContextById(live.dispatchId)?.status).toBe('failed') }) it.each(['local', 'federated'] as const)( @@ -288,7 +280,6 @@ describe('Task/Dispatch lifecycle guards', () => { expect(database.getWorkerDispatch(failed.dispatch.id)?.state).toBe('failed') expect(database.getDispatchContextById(live.dispatchId)?.status).toBe('dispatched') expect(database.getWorkerDispatch(live.dispatchId)?.state).toBe('ready') - expectCapability(database, live, true) } ) @@ -429,7 +420,7 @@ describe('Task/Dispatch lifecycle guards', () => { const released = operation === 'stop' ? database.beginWorkerStop(contextOnly.id, 'runtime_test') - : database.abandonWorkerDispatch(contextOnly.id) + : database.abandonWorkerDispatch(contextOnly.id, 'epoch_test') expect(released).toMatchObject({ disposition: 'context_only', alreadySettled: false, @@ -464,7 +455,7 @@ describe('Task/Dispatch lifecycle guards', () => { const released = operation === 'stop' ? database.beginWorkerStop(contextOnly.id, 'runtime_test') - : database.abandonWorkerDispatch(contextOnly.id) + : database.abandonWorkerDispatch(contextOnly.id, 'epoch_test') expect(released).toMatchObject({ disposition: 'context_only', @@ -523,12 +514,13 @@ describe('Task/Dispatch lifecycle guards', () => { ) expect(database.settleWorkerStop(released.dispatchId).state).toBe('stopped') } else { - expect(database.abandonWorkerDispatch(released.dispatchId).disposition).toBe('abandoned') + expect(database.abandonWorkerDispatch(released.dispatchId, 'epoch_test').disposition).toBe( + 'abandoned' + ) } expect(database.getTask(task.id)?.status).toBe('dispatched') expect(database.getDispatchContextById(live.dispatchId)?.status).toBe('dispatched') - expectCapability(database, live, true) expect( database.settleWorkerReport({ taskId: task.id, @@ -538,7 +530,7 @@ describe('Task/Dispatch lifecycle guards', () => { }) ).toEqual({ action: 'settled', outcome: 'succeeded', duplicate: false }) expect(database.getTask(task.id)?.status).toBe('completed') - expectCapability(database, live, false) + expect(database.getDispatchContextById(live.dispatchId)?.status).toBe('completed') } ) @@ -555,7 +547,9 @@ describe('Task/Dispatch lifecycle guards', () => { expect(database.beginWorkerStop(stopping.dispatchId, 'runtime_test').disposition).toBe( 'stopping' ) - expect(database.abandonWorkerDispatch(abandoned.dispatchId).disposition).toBe('abandoned') + expect(database.abandonWorkerDispatch(abandoned.dispatchId, 'epoch_test').disposition).toBe( + 'abandoned' + ) expect(database.getTask(task.id)?.status).toBe('dispatched') expect(database.settleWorkerStop(stopping.dispatchId).state).toBe('stopped') @@ -656,7 +650,6 @@ describe('Task/Dispatch lifecycle guards', () => { expect(database.getTask(task.id)?.status).toBe('dispatched') expect(database.getDispatchContextById(worker.dispatchId)?.status).toBe('dispatched') expect(database.getWorkerDispatch(worker.dispatchId)?.state).toBe('ready') - expectCapability(database, worker, true) }) it('rolls back gate resolution when an active Dispatch blocks readiness', () => { @@ -700,7 +693,7 @@ function startWorker(database: OrchestrationDb, taskId: string, name: string): W const paneKey = `tab_${name}:aaaaaaaa-aaaa-4aaa-8aaa-${paneSuffix}` const processIncarnation = `${name}:1` const handle = `term_${name}` - const capability = database.prepareStartingWorkerAuthority({ + database.prepareStartingWorkerAuthority({ dispatchId: started.dispatch.id, handle, paneKey, @@ -711,18 +704,7 @@ function startWorker(database: OrchestrationDb, taskId: string, name: string): W terminalOwnership: 'created' }) database.markWorkerDispatchReady(started.dispatch.id) - return { dispatchId: started.dispatch.id, capability, handle, paneKey, processIncarnation } -} - -function expectCapability(database: OrchestrationDb, worker: WorkerFixture, valid: boolean): void { - expect( - database.verifyDispatchCapability({ - dispatchId: worker.dispatchId, - capability: worker.capability, - paneKey: worker.paneKey, - processIncarnation: worker.processIncarnation - }).valid - ).toBe(valid) + return { dispatchId: started.dispatch.id, handle, paneKey, processIncarnation } } function sqliteFor(database: OrchestrationDb): Database.Database { diff --git a/src/main/runtime/orchestration/db-task-dispatch-races.test.ts b/src/main/runtime/orchestration/db-task-dispatch-races.test.ts index c671aa4566b0..9398f281e43c 100644 --- a/src/main/runtime/orchestration/db-task-dispatch-races.test.ts +++ b/src/main/runtime/orchestration/db-task-dispatch-races.test.ts @@ -126,7 +126,7 @@ describe('Task/Dispatch concurrency', () => { taskId: task.id, startOptions: {} }) - const capability = first.db.prepareStartingWorkerAuthority({ + first.db.prepareStartingWorkerAuthority({ dispatchId: started.dispatch.id, handle: 'term_worker', paneKey: 'tab_worker:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', @@ -171,14 +171,6 @@ describe('Task/Dispatch concurrency', () => { status: 'completed', last_failure: null }) - expect( - first.db.verifyDispatchCapability({ - dispatchId: started.dispatch.id, - capability, - paneKey: 'tab_worker:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', - processIncarnation: 'worker:1' - }) - ).toMatchObject({ valid: false }) }) it('keeps nested dispatch failure atomic with its caller transaction', () => { @@ -229,10 +221,11 @@ describe('Task/Dispatch concurrency', () => { }) const sqlite = sqliteFor(first.db) const exec = sqlite.exec.bind(sqlite) - let winningCapability: string | undefined + let winnerClaimed = false vi.spyOn(sqlite, 'exec').mockImplementation((sql) => { - if (!winningCapability && sql === 'BEGIN IMMEDIATE') { - winningCapability = concurrent.db.prepareStartingWorkerAuthority({ + if (!winnerClaimed && sql === 'BEGIN IMMEDIATE') { + winnerClaimed = true + concurrent.db.prepareStartingWorkerAuthority({ dispatchId: winner.dispatch.id, handle: 'term_reminted', paneKey: 'tab_new:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', @@ -258,10 +251,9 @@ describe('Task/Dispatch concurrency', () => { terminalOwnership: 'created' }) ).toThrow(`already has an active dispatch (${winner.dispatch.id} for task ${winningTask.id})`) - expect(winningCapability).toBeDefined() expect(first.db.getDispatchContextById(loser.dispatch.id)).toMatchObject({ assignee_handle: null, - capability_hash: null + process_incarnation: null }) expect(first.db.getWorkerDispatch(loser.dispatch.id)).toMatchObject({ stage: 'accepted', @@ -270,7 +262,7 @@ describe('Task/Dispatch concurrency', () => { expect(first.db.getWorkerTerminalResourceByOwner(loser.dispatch.id)).toBeUndefined() expect(first.db.getDispatchContextById(winner.dispatch.id)).toMatchObject({ assignee_handle: 'term_reminted', - capability_hash: expect.any(String) + process_incarnation: 'winner:1' }) expect(first.db.getWorkerTerminalResourceByOwner(winner.dispatch.id)).toMatchObject({ terminal_handle: 'term_reminted', @@ -280,7 +272,7 @@ describe('Task/Dispatch concurrency', () => { sqlite .prepare( `SELECT COUNT(*) AS count FROM dispatch_contexts - WHERE status IN ('pending', 'dispatched') AND capability_hash IS NOT NULL` + WHERE status IN ('pending', 'dispatched') AND process_incarnation IS NOT NULL` ) .get() ).toEqual({ count: 1 }) diff --git a/src/main/runtime/orchestration/db/attach-orchestration-db-methods.ts b/src/main/runtime/orchestration/db/attach-orchestration-db-methods.ts index 135dcc5ae3fe..2c90cab8e00e 100644 --- a/src/main/runtime/orchestration/db/attach-orchestration-db-methods.ts +++ b/src/main/runtime/orchestration/db/attach-orchestration-db-methods.ts @@ -1,7 +1,6 @@ import { attachAttemptObservationStore } from './attempt-observation-store' import { attachCoordinatorRunStore } from './coordinator-runs/coordinator-run-store' import { attachDecisionGateStore } from './decision-gates/decision-gate-store' -import { attachDispatchCapability } from './dispatch-context/dispatch-capability' import { attachDispatchCompletion } from './dispatch-context/dispatch-completion' import { attachDispatchContextStore } from './dispatch-context/dispatch-context-store' import { attachDispatchLookup } from './dispatch-context/dispatch-lookup' @@ -128,7 +127,6 @@ export function attachOrchestrationDbMethods(ctor: { prototype: object }): void attachWorkerTerminalArchive(ctor) attachWorkerTerminalListing(ctor) attachDispatchContextStore(ctor) - attachDispatchCapability(ctor) attachDispatchLookup(ctor) attachDispatchDepth(ctor) attachDispatchCompletion(ctor) diff --git a/src/main/runtime/orchestration/db/dispatch-capability-hash.ts b/src/main/runtime/orchestration/db/dispatch-capability-hash.ts deleted file mode 100644 index c4e6631920cb..000000000000 --- a/src/main/runtime/orchestration/db/dispatch-capability-hash.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createHash } from 'node:crypto' - -export function hashDispatchCapability(capability: string): string { - return createHash('sha256').update(capability).digest('hex') -} diff --git a/src/main/runtime/orchestration/db/dispatch-context/dispatch-capability.test.ts b/src/main/runtime/orchestration/db/dispatch-context/dispatch-capability.test.ts deleted file mode 100644 index 2bacb42479eb..000000000000 --- a/src/main/runtime/orchestration/db/dispatch-context/dispatch-capability.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { OrchestrationDb } from '../orchestration-db' -import { createRootDispatch } from '../root-dispatch-test-fixture' - -describe('verifyDispatchCapability', () => { - let db: OrchestrationDb - - beforeEach(() => { - db = new OrchestrationDb(':memory:') - }) - - afterEach(() => { - db.close() - }) - - function mintedWorker() { - const run = db.createRun({ - objective: 'capability guidance', - coordinatorHandle: 'term_coord', - coordinatorPaneKey: 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' - }) - const task = db.createTask({ spec: 'work', runId: run.id }) - const paneKey = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' - const dispatch = createRootDispatch(db, task.id, 'term_worker', paneKey) - const capability = db.mintDispatchCapability({ - dispatchId: dispatch.id, - paneKey, - processIncarnation: 'worker:1' - }) - return { dispatchId: dispatch.id, paneKey, capability } - } - - // Why: the caller that hits this is an agent reading only the error string, so it must name the flag. - it('names the flag that supplies a missing capability', () => { - const worker = mintedWorker() - - const result = db.verifyDispatchCapability({ - dispatchId: worker.dispatchId, - capability: undefined, - paneKey: worker.paneKey, - processIncarnation: 'worker:1' - }) - - expect(result).toMatchObject({ valid: false }) - expect(result.valid === false && result.reason).toContain('--dispatch-capability') - }) - - it('still accepts the minted capability', () => { - const worker = mintedWorker() - - expect( - db.verifyDispatchCapability({ - dispatchId: worker.dispatchId, - capability: worker.capability, - paneKey: worker.paneKey, - processIncarnation: 'worker:1' - }) - ).toEqual({ valid: true }) - }) -}) diff --git a/src/main/runtime/orchestration/db/dispatch-context/dispatch-capability.ts b/src/main/runtime/orchestration/db/dispatch-context/dispatch-capability.ts deleted file mode 100644 index 007e81d10164..000000000000 --- a/src/main/runtime/orchestration/db/dispatch-context/dispatch-capability.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { randomBytes, timingSafeEqual } from 'node:crypto' -import { OrchestrationError } from '../../orchestration-error' -import { hashDispatchCapability } from '../dispatch-capability-hash' -import { isEquivalentPaneKey } from '../pane-key-match' -import type { OrchestrationDb } from '../orchestration-db' - -export function mintDispatchCapability( - this: OrchestrationDb, - params: { - dispatchId: string - paneKey: string - processIncarnation: string - } -): string { - const dispatch = this.getDispatchContextById(params.dispatchId) - if (!dispatch || (dispatch.status !== 'pending' && dispatch.status !== 'dispatched')) { - throw new OrchestrationError( - 'dispatch_inactive', - `Dispatch ${params.dispatchId} is not active.` - ) - } - const capability = `dcap_${randomBytes(32).toString('base64url')}` - // Why: re-pointing the Dispatch at a pane/process must fence the prior consumer's Delivery in - // the same transaction, or both processes keep acking one outstanding Delivery. - this.db.exec('BEGIN IMMEDIATE') - try { - this.db - .prepare( - `UPDATE dispatch_contexts - SET capability_hash = ?, assignee_pane_key = ?, process_incarnation = ?, - capability_revoked_at = NULL, - consumer_generation = consumer_generation + 1 - WHERE id = ?` - ) - .run( - hashDispatchCapability(capability), - params.paneKey, - params.processIncarnation, - params.dispatchId - ) - this.fenceUnacknowledgedMailboxDeliveries(`dispatch:${params.dispatchId}`) - this.db.exec('COMMIT') - } catch (error) { - this.db.exec('ROLLBACK') - throw error - } - return capability -} - -export function verifyDispatchCapability( - this: OrchestrationDb, - params: { - dispatchId: string - capability: string | undefined - paneKey: string | undefined - processIncarnation: string | undefined - } -): { valid: true } | { valid: false; reason: string } { - const dispatch = this.getDispatchContextById(params.dispatchId) - if (!dispatch) { - return { valid: false, reason: `Dispatch ${params.dispatchId} was not found.` } - } - if (!dispatch.capability_hash) { - return { valid: false, reason: `Dispatch ${params.dispatchId} has no lifecycle capability.` } - } - if (dispatch.capability_revoked_at) { - return { valid: false, reason: `Dispatch ${params.dispatchId} capability is revoked.` } - } - if (!params.capability) { - // Why: a worker that omits the flag needs the flag name, not just the diagnosis. - return { - valid: false, - reason: - 'The Dispatch capability is missing. Pass --dispatch-capability from your dispatch preamble.' - } - } - const expected = Buffer.from(dispatch.capability_hash, 'hex') - const observed = Buffer.from(hashDispatchCapability(params.capability), 'hex') - if (expected.length !== observed.length || !timingSafeEqual(expected, observed)) { - return { valid: false, reason: 'The Dispatch capability is invalid.' } - } - if ( - !dispatch.assignee_pane_key || - !params.paneKey || - !isEquivalentPaneKey(dispatch.assignee_pane_key, params.paneKey) - ) { - return { valid: false, reason: 'The caller is not the Dispatch pane.' } - } - if ( - !dispatch.process_incarnation || - !params.processIncarnation || - dispatch.process_incarnation !== params.processIncarnation - ) { - return { valid: false, reason: 'The Dispatch process incarnation changed.' } - } - return { valid: true } -} - -export function revokeDispatchCapability(this: OrchestrationDb, dispatchId: string): void { - this.db - .prepare( - `UPDATE dispatch_contexts - SET capability_revoked_at = COALESCE(capability_revoked_at, datetime('now')) - WHERE id = ?` - ) - .run(dispatchId) -} - -export type DispatchCapabilityMethods = { - mintDispatchCapability: typeof mintDispatchCapability - verifyDispatchCapability: typeof verifyDispatchCapability - revokeDispatchCapability: typeof revokeDispatchCapability -} - -export function attachDispatchCapability(ctor: { prototype: object }): void { - Object.assign(ctor.prototype, { - mintDispatchCapability, - verifyDispatchCapability, - revokeDispatchCapability - }) -} diff --git a/src/main/runtime/orchestration/db/dispatch-context/worker-report-settlement.ts b/src/main/runtime/orchestration/db/dispatch-context/worker-report-settlement.ts index ddb0e383143c..9768b83ac5be 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/worker-report-settlement.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/worker-report-settlement.ts @@ -5,6 +5,11 @@ import { settleActiveDispatchesForTask } from './dispatch-completion' import { getActiveDispatchForTask } from './task-dispatch-reconciliation' import { transitionLifecycleWithDb } from '../lifecycle-transition' import { runLifecycleWriteTransaction } from '../lifecycle-write-transaction-runner' +import { + isWorkerStateIn, + SETTLEABLE_WORKER_STATES, + UNPROVEN_WORKER_STATES +} from '../../worker-report-admission' type WorkerReportObservation = { id: string @@ -105,22 +110,23 @@ export function settleWorkerReportInTransaction( recordAcceptedReportFact(this, params) return { action: 'settled', outcome: params.outcome, duplicate: true } } - const reconnectingStart = + const unprovenWorker = isWorkerStateIn(UNPROVEN_WORKER_STATES, reportingWorker?.state) + const reconnectingWorker = (dispatch.status === 'pending' || dispatch.status === 'dispatched') && task.status === 'blocked' && - reportingWorker?.state === 'start_unknown' + unprovenWorker const reportingStart = dispatch.status === 'pending' && task.status === 'dispatched' && reportingWorker?.state === 'starting' const previousDispatchStatus = settledByUnobservedPrompt ? 'failed' - : reconnectingStart || reportingStart + : reconnectingWorker || reportingStart ? dispatch.status : 'dispatched' const previousTaskStatus = settledByUnobservedPrompt ? 'failed' - : reconnectingStart + : reconnectingWorker ? 'blocked' : 'dispatched' if (dispatch.status !== previousDispatchStatus || task.status !== previousTaskStatus) { @@ -191,7 +197,7 @@ export function settleWorkerReportInTransaction( dispatchUpdate = { changes: dispatchTransition.changed ? 1 : 0 } taskUpdate = { changes: taskTransition.changed ? 1 : 0 } } else { - if (reconnectingStart) { + if (reconnectingWorker) { transitionLifecycleWithDb(this.db, { entity: 'task', id: params.taskId, @@ -202,7 +208,7 @@ export function settleWorkerReportInTransaction( const dispatchTransition = transitionLifecycleWithDb(this.db, { entity: 'dispatch', id: params.dispatchId, - from: reconnectingStart || reportingStart ? ['pending', 'dispatched'] : 'dispatched', + from: reconnectingWorker || reportingStart ? ['pending', 'dispatched'] : 'dispatched', to: expectedDispatchStatus, projection: { completed_at: new Date().toISOString(), @@ -238,12 +244,13 @@ export function settleWorkerReportInTransaction( projection: { stage: 'settled', updated_at: new Date().toISOString() }, correction: 'unobserved_prompt_report' }) - } else if ((reconnectingStart || reportingStart) && params.outcome === 'succeeded') { + } else if ((unprovenWorker || reportingStart) && params.outcome === 'succeeded') { transitionLifecycleWithDb(this.db, { entity: 'worker', id: params.dispatchId, - from: reportingStart ? 'starting' : 'start_unknown', - to: 'ready' + from: reportingStart ? 'starting' : UNPROVEN_WORKER_STATES, + to: 'ready', + projection: { last_error: null } }) transitionLifecycleWithDb(this.db, { entity: 'worker', @@ -256,8 +263,8 @@ export function settleWorkerReportInTransaction( transitionLifecycleWithDb(this.db, { entity: 'worker', id: params.dispatchId, - // A start_unknown success report reconnects through 'ready' above; only failure settles here. - from: params.outcome === 'succeeded' ? 'ready' : ['ready', 'start_unknown', 'starting'], + // An unknown worker's success report reconnects through 'ready' above; only failure settles here. + from: params.outcome === 'succeeded' ? 'ready' : [...SETTLEABLE_WORKER_STATES, 'starting'], to: params.outcome === 'succeeded' ? 'succeeded' : 'failed', projection: { stage: 'settled', updated_at: new Date().toISOString() } }) diff --git a/src/main/runtime/orchestration/db/dispatch-mailbox-consumer-fencing.test.ts b/src/main/runtime/orchestration/db/dispatch-mailbox-consumer-fencing.test.ts index 608b91a2586e..34004e6a7afb 100644 --- a/src/main/runtime/orchestration/db/dispatch-mailbox-consumer-fencing.test.ts +++ b/src/main/runtime/orchestration/db/dispatch-mailbox-consumer-fencing.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { OrchestrationDb } from '../db' import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-version' -import { createRootDispatch } from './root-dispatch-test-fixture' +import { createRootDispatch, reattachDispatchConsumer } from './root-dispatch-test-fixture' import type { DeliveryRow } from '../types' const PANE_A = 'tab_a:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' @@ -54,7 +54,7 @@ describe('dispatch mailbox consumer fencing', () => { it('fences worker A once worker B re-attaches, and hands B the same unread mail', () => { const dispatch = dispatchWithMail(['first', 'second']) - db.mintDispatchCapability({ + reattachDispatchConsumer(db, { dispatchId: dispatch.id, paneKey: PANE_A, processIncarnation: 'runtime:pty-a:1' @@ -63,7 +63,7 @@ describe('dispatch mailbox consumer fencing', () => { const deliveryA = openDelivery(dispatch.id, dispatch.runId, generationA) expect(deliveryA?.messages.map((message) => message.subject)).toEqual(['first', 'second']) - db.mintDispatchCapability({ + reattachDispatchConsumer(db, { dispatchId: dispatch.id, paneKey: PANE_B, processIncarnation: 'runtime:pty-b:1' @@ -97,7 +97,7 @@ describe('dispatch mailbox consumer fencing', () => { it("leaves A's ack able to strand mail unread only when B never took over", () => { const dispatch = dispatchWithMail(['first']) - db.mintDispatchCapability({ + reattachDispatchConsumer(db, { dispatchId: dispatch.id, paneKey: PANE_A, processIncarnation: 'runtime:pty-a:1' diff --git a/src/main/runtime/orchestration/db/federation/federation-relay-enqueue.ts b/src/main/runtime/orchestration/db/federation/federation-relay-enqueue.ts index 28841f44e6e0..e98a2236aeb9 100644 --- a/src/main/runtime/orchestration/db/federation/federation-relay-enqueue.ts +++ b/src/main/runtime/orchestration/db/federation/federation-relay-enqueue.ts @@ -6,6 +6,7 @@ import type { import { OrchestrationError } from '../../orchestration-error' import { generateId } from '../generated-id' import type { OrchestrationDb } from '../orchestration-db' +import { isWorkerStateIn, SETTLEABLE_WORKER_STATES } from '../../worker-report-admission' export function enqueueFederationRelay( this: OrchestrationDb, @@ -31,7 +32,7 @@ export function enqueueFederationRelay( try { if (params.settleRemoteOutcome) { const attachment = this.getRemoteDispatchAttachment(params.dispatchId) - if (!attachment || attachment.state !== 'ready') { + if (!attachment || !isWorkerStateIn(SETTLEABLE_WORKER_STATES, attachment.state)) { throw new OrchestrationError( 'dispatch_inactive', `Remote Dispatch ${params.dispatchId} is not active.` diff --git a/src/main/runtime/orchestration/db/federation/federation-relay-item.ts b/src/main/runtime/orchestration/db/federation/federation-relay-item.ts index 1ae13b6b6ae0..dac7b8f2c3a4 100644 --- a/src/main/runtime/orchestration/db/federation/federation-relay-item.ts +++ b/src/main/runtime/orchestration/db/federation/federation-relay-item.ts @@ -5,6 +5,7 @@ import type { } from '../../types' import { OrchestrationError } from '../../orchestration-error' import type { OrchestrationDb } from '../orchestration-db' +import { isWorkerStateIn, SETTLEABLE_WORKER_STATES } from '../../worker-report-admission' export function getFederationRelayItem( this: OrchestrationDb, @@ -40,7 +41,7 @@ export function settleRemoteAttachmentInRelayTransaction( if (attachment.state === state) { return } - if (attachment.state !== 'ready') { + if (!isWorkerStateIn(SETTLEABLE_WORKER_STATES, attachment.state)) { throw new OrchestrationError( 'request_mismatch', `Remote Dispatch ${dispatchId} cannot settle as ${state} from ${attachment.state}.` @@ -51,9 +52,9 @@ export function settleRemoteAttachmentInRelayTransaction( `UPDATE remote_dispatch_attachments SET state = ?, stage = ?, capability_hash = NULL, updated_at = datetime('now') - WHERE dispatch_id = ? AND state = 'ready'` + WHERE dispatch_id = ? AND state = ?` ) - .run(state, stage, dispatchId) + .run(state, stage, dispatchId, attachment.state) } export type FederationRelayItemMethods = { diff --git a/src/main/runtime/orchestration/db/federation/remote-dispatch-attachment-authority.ts b/src/main/runtime/orchestration/db/federation/remote-dispatch-attachment-authority.ts index ac85fc7c3c99..04faf60a79ac 100644 --- a/src/main/runtime/orchestration/db/federation/remote-dispatch-attachment-authority.ts +++ b/src/main/runtime/orchestration/db/federation/remote-dispatch-attachment-authority.ts @@ -1,7 +1,5 @@ -import { randomBytes, timingSafeEqual } from 'node:crypto' import type { RemoteDispatchAttachmentRow } from '../../types' import { OrchestrationError } from '../../orchestration-error' -import { hashDispatchCapability } from '../dispatch-capability-hash' import { isEquivalentPaneKey } from '../pane-key-match' import type { OrchestrationDb } from '../orchestration-db' @@ -18,7 +16,7 @@ export function prepareRemoteAttachmentAuthority( hostScope?: string | null terminalOwnership?: 'created' | 'external' } -): string { +): void { this.db.exec('BEGIN IMMEDIATE') try { const attachment = this.getRemoteDispatchAttachment(params.dispatchId) @@ -35,18 +33,16 @@ export function prepareRemoteAttachmentAuthority( `Terminal ${params.terminalHandle} already has active remote Dispatch ${active.dispatch_id}.` ) } - const capability = `dcap_${randomBytes(32).toString('base64url')}` const result = this.db .prepare( `UPDATE remote_dispatch_attachments - SET stage = 'authority_attached', capability_hash = ?, pane_key = ?, + SET stage = 'authority_attached', pane_key = ?, process_incarnation = ?, worktree_id = ?, terminal_handle = ?, setup_state = ?, effects = ?, residual_resources = ?, updated_at = datetime('now'), consumer_generation = consumer_generation + 1 WHERE dispatch_id = ? AND state = 'starting'` ) .run( - hashDispatchCapability(capability), params.paneKey, params.processIncarnation, params.worktreeId, @@ -108,7 +104,6 @@ export function prepareRemoteAttachmentAuthority( } } this.db.exec('COMMIT') - return capability } catch (error) { this.db.exec('ROLLBACK') throw error @@ -162,32 +157,6 @@ export function failRemoteAttachment( return this.getRemoteDispatchAttachment(dispatchId) as RemoteDispatchAttachmentRow } -export function verifyRemoteAttachmentAuthority( - this: OrchestrationDb, - params: { - dispatchId: string - capability: string | undefined - paneKey: string | null - processIncarnation: string | null - } -): boolean { - const attachment = this.getRemoteDispatchAttachment(params.dispatchId) - if ( - !attachment?.capability_hash || - !params.capability || - !attachment.pane_key || - !params.paneKey || - !isEquivalentPaneKey(attachment.pane_key, params.paneKey) || - !attachment.process_incarnation || - attachment.process_incarnation !== params.processIncarnation - ) { - return false - } - const expected = Buffer.from(attachment.capability_hash, 'hex') - const observed = Buffer.from(hashDispatchCapability(params.capability), 'hex') - return expected.length === observed.length && timingSafeEqual(expected, observed) -} - export function isRemoteAttachmentProcessCurrent( this: OrchestrationDb, params: { @@ -210,7 +179,6 @@ export type RemoteDispatchAttachmentAuthorityMethods = { prepareRemoteAttachmentAuthority: typeof prepareRemoteAttachmentAuthority markRemoteAttachmentReady: typeof markRemoteAttachmentReady failRemoteAttachment: typeof failRemoteAttachment - verifyRemoteAttachmentAuthority: typeof verifyRemoteAttachmentAuthority isRemoteAttachmentProcessCurrent: typeof isRemoteAttachmentProcessCurrent } @@ -219,7 +187,6 @@ export function attachRemoteDispatchAttachmentAuthority(ctor: { prototype: objec prepareRemoteAttachmentAuthority, markRemoteAttachmentReady, failRemoteAttachment, - verifyRemoteAttachmentAuthority, isRemoteAttachmentProcessCurrent }) } diff --git a/src/main/runtime/orchestration/db/lifecycle-transition.ts b/src/main/runtime/orchestration/db/lifecycle-transition.ts index 6fcc40f19131..25d2887141c2 100644 --- a/src/main/runtime/orchestration/db/lifecycle-transition.ts +++ b/src/main/runtime/orchestration/db/lifecycle-transition.ts @@ -89,7 +89,7 @@ const LEGAL_TRANSITIONS: Record { @@ -213,7 +213,7 @@ describe('derived delivery migration', () => { processIncarnation: 'worker:2' } if (consumerSource === 'dispatch') { - peer.mintDispatchCapability(authority) + reattachDispatchConsumer(peer, authority) } else { peer.prepareRemoteAttachmentAuthority({ ...authority, diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-abandon.test.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-abandon.test.ts new file mode 100644 index 000000000000..1878c72a72bd --- /dev/null +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-abandon.test.ts @@ -0,0 +1,191 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { OrchestrationDb } from '../orchestration-db' + +const THIS_RUNTIME = 'epoch_this_runtime' + +describe('worker-abandon settles a stuck worker', () => { + let db: OrchestrationDb + beforeEach(() => { + db = new OrchestrationDb(':memory:') + }) + afterEach(() => db.close()) + + function startWorker(taskId?: string, name = 'w') { + const task = taskId + ? db.getTask(taskId)! + : db.createTask({ runId: 'run_legacy_local', spec: 'abandon work' }) + const { dispatch } = db.createStartingWorkerDispatch({ + taskId: task.id, + startOptions: {}, + creator: { kind: 'system' }, + maxDepth: 9 + }) + db.prepareStartingWorkerAuthority({ + dispatchId: dispatch.id, + handle: `term_${name}`, + paneKey: `tab_${name}:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa${name.length}`, + processIncarnation: `inc_${name}`, + worktreeId: 'wt', + effects: [], + setupState: 'not_configured', + terminalOwnership: 'created' + }) + return { task, dispatch } + } + + function readyWorker(taskId?: string, name?: string) { + const started = startWorker(taskId, name) + db.markWorkerDispatchReady(started.dispatch.id) + return started + } + + function setReleaseState(dispatchId: string, releaseState: string) { + db.db + .prepare('UPDATE worker_terminal_resources SET release_state = ? WHERE owner_dispatch_id = ?') + .run(releaseState, dispatchId) + } + + function storeArchive(dispatchId: string) { + const resource = db.getWorkerTerminalResourceByOwner(dispatchId)! + db.db + .prepare( + "INSERT INTO worker_terminal_archives (dispatch_id, resource_id, kind, content) VALUES (?, ?, 'terminal_tail', '{}')" + ) + .run(dispatchId, resource.id) + } + + function expectAbandoned(dispatchId: string, taskId: string, priorError?: string) { + expect(db.getWorkerDispatch(dispatchId)).toMatchObject({ + state: 'abandoned', + stage: 'abandoned', + last_error: [priorError, 'Abandoned by term_coordinator.'].filter(Boolean).join(' ') + }) + expect(db.getDispatchContextById(dispatchId)).toMatchObject({ + status: 'failed', + last_failure: 'abandoned', + capability_revoked_at: expect.any(String) + }) + expect(db.getTask(taskId)?.status).toBe('blocked') + } + + it('settles a stop another runtime left stranded in stopping', () => { + const { task, dispatch } = readyWorker() + db.beginWorkerStop(dispatch.id, 'epoch_dead_runtime') + + expect(db.abandonWorkerDispatch(dispatch.id, THIS_RUNTIME, 'term_coordinator')).toMatchObject({ + disposition: 'abandoned' + }) + expectAbandoned(dispatch.id, task.id) + }) + + it.each([ + [ + 'stop_unknown', + 'the terminal is external', + (id: string) => { + db.beginWorkerStop(id, THIS_RUNTIME) + db.markWorkerStopUnknown(id, 'the terminal is external') + } + ], + [ + 'start_unknown', + 'lost contact', + (id: string) => db.markWorkerStartUnknown(id, 'prompt', 'lost contact') + ] + ] as const)('settles a %s worker and records who abandoned it', (state, priorError, reach) => { + const { task, dispatch } = startWorker() + if (state === 'stop_unknown') { + db.markWorkerDispatchReady(dispatch.id) + } + reach(dispatch.id) + + expect(db.abandonWorkerDispatch(dispatch.id, THIS_RUNTIME, 'term_coordinator')).toMatchObject({ + disposition: 'abandoned' + }) + expectAbandoned(dispatch.id, task.id, priorError) + }) + + it('says so when it cannot tell who abandoned the worker', () => { + const { dispatch } = readyWorker() + db.abandonWorkerDispatch(dispatch.id, THIS_RUNTIME) + + expect(db.getWorkerDispatch(dispatch.id)?.last_error).toBe( + 'Abandoned by an unidentified caller.' + ) + }) + + it('settles an active attempt that is no longer the Task latest without touching the newer one', () => { + const first = readyWorker(undefined, 'first') + // An interleaved re-dispatch leaves two active attempts; the older one used to be a no-op. + db.db.prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(first.task.id) + const second = readyWorker(first.task.id, 'second') + + expect(db.abandonWorkerDispatch(first.dispatch.id, THIS_RUNTIME).disposition).toBe('abandoned') + expect(db.getWorkerDispatch(first.dispatch.id)?.state).toBe('abandoned') + expect(db.getWorkerDispatch(second.dispatch.id)?.state).toBe('ready') + expect(db.getTask(first.task.id)?.status).toBe('dispatched') + }) + + it.each(['requested', 'not_requested'] as const)( + 'retains the terminal of a worker it settles whose release is %s', + (releaseState) => { + const { dispatch } = readyWorker() + setReleaseState(dispatch.id, releaseState) + storeArchive(dispatch.id) + + expect(db.abandonWorkerDispatch(dispatch.id, THIS_RUNTIME).disposition).toBe('abandoned') + expect(db.getWorkerTerminalResourceByOwner(dispatch.id)).toMatchObject({ + ownership_state: 'owned', + release_state: 'retained', + retained_reason: 'user_requested' + }) + expect(db.getWorkerTerminalArchive(dispatch.id)).toBeUndefined() + } + ) + + it('leaves a settled worker and its terminal release untouched', () => { + const { dispatch } = readyWorker() + db.failDispatch(dispatch.id, 'operator closed the tab', { workerProcessExited: true }) + setReleaseState(dispatch.id, 'requested') + + expect(db.abandonWorkerDispatch(dispatch.id, THIS_RUNTIME).disposition).toBe('already_settled') + expect(db.getWorkerTerminalResourceByOwner(dispatch.id)?.release_state).toBe('requested') + }) + + it('keeps a committed release and a terminal Orca does not own as they are', () => { + const releasing = readyWorker(undefined, 'releasing') + setReleaseState(releasing.dispatch.id, 'releasing') + const unknown = readyWorker(undefined, 'unknown') + setReleaseState(unknown.dispatch.id, 'unknown') + storeArchive(unknown.dispatch.id) + const external = readyWorker(undefined, 'external') + db.db + .prepare( + "UPDATE worker_terminal_resources SET ownership_state = 'external', release_state = 'unknown' WHERE owner_dispatch_id = ?" + ) + .run(external.dispatch.id) + + db.abandonWorkerDispatch(releasing.dispatch.id, THIS_RUNTIME) + db.abandonWorkerDispatch(unknown.dispatch.id, THIS_RUNTIME) + db.abandonWorkerDispatch(external.dispatch.id, THIS_RUNTIME) + + expect(db.getWorkerTerminalResourceByOwner(releasing.dispatch.id)?.release_state).toBe( + 'releasing' + ) + expect(db.getWorkerTerminalResourceByOwner(unknown.dispatch.id)?.release_state).toBe('unknown') + expect(db.getWorkerTerminalArchive(unknown.dispatch.id)).toBeDefined() + expect(db.getWorkerTerminalResourceByOwner(external.dispatch.id)).toMatchObject({ + ownership_state: 'external', + release_state: 'unknown' + }) + }) + + it('reports an abandon repeated on an abandoned worker as already abandoned', () => { + const { dispatch } = readyWorker() + db.abandonWorkerDispatch(dispatch.id, THIS_RUNTIME) + + expect(db.abandonWorkerDispatch(dispatch.id, THIS_RUNTIME).disposition).toBe( + 'already_abandoned' + ) + }) +}) diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-abandon.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-abandon.ts index 7549cb4eb26e..a20e483e7d19 100644 --- a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-abandon.ts +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-abandon.ts @@ -7,14 +7,20 @@ import { import type { OrchestrationDb } from '../orchestration-db' import { reconcileTaskAfterDispatchInterruption } from '../dispatch-context/task-dispatch-reconciliation' import { transitionLifecycleWithDb } from '../lifecycle-transition' +import { WORKER_SETTLED_STATES } from '../../worker-terminal-ownership' +import { isStopStrandedByAnotherRuntime } from './worker-dispatch-stop' +import { retainTerminalResourceInTransaction } from '../worker-terminal/worker-terminal-archive' export function abandonWorkerDispatch( this: OrchestrationDb, - dispatchId: string + dispatchId: string, + runtimeEpoch: string, + abandonedBy?: string ): | { - disposition: 'abandoned' | 'already_abandoned' | 'stale' + disposition: 'abandoned' | 'already_abandoned' | 'already_settled' worker: WorkerDispatchRow + superseded: boolean } | ({ disposition: 'context_only' } & ContextOnlyDispatchReleaseResult) { this.db.exec('BEGIN IMMEDIATE') @@ -32,54 +38,63 @@ export function abandonWorkerDispatch( this.db.exec('COMMIT') return { disposition: 'context_only', ...released } } - if (worker.state === 'abandoned') { - this.db.exec('COMMIT') - return { disposition: 'already_abandoned', worker } - } - if (this.getDispatchContext(dispatch.task_id)?.id !== dispatchId) { - this.db.exec('COMMIT') - return { disposition: 'stale', worker } - } - if (worker.state === 'stopping') { + // Only this runtime's own in-flight stop is refused: it always ends in stopped or stop_unknown. + if (worker.state === 'stopping' && !isStopStrandedByAnotherRuntime(worker, runtimeEpoch)) { throw new OrchestrationError( 'dispatch_inactive', `Dispatch ${dispatchId} is stopping; wait for worker-stop to settle before abandoning.` ) } - if (worker.state === 'failed' || worker.state === 'stopped') { - this.db.exec('COMMIT') - return { disposition: 'stale', worker } - } - if (worker.state === 'succeeded') { - throw new OrchestrationError( - 'dispatch_inactive', - `Dispatch ${dispatchId} already succeeded and cannot be abandoned.` - ) - } - transitionLifecycleWithDb(this.db, { - entity: 'worker', - id: dispatchId, - from: worker.state, - to: 'abandoned', - projection: { stage: 'abandoned', updated_at: new Date().toISOString() } - }) - if (['pending', 'dispatched'].includes(dispatch.status)) { + const settles = !WORKER_SETTLED_STATES.includes(worker.state) + const superseded = this.getDispatchContext(dispatch.task_id)?.id !== dispatchId + if (settles) { + const now = new Date().toISOString() transitionLifecycleWithDb(this.db, { - entity: 'dispatch', + entity: 'worker', id: dispatchId, - from: dispatch.status, - to: 'failed', + from: worker.state, + to: 'abandoned', projection: { - capability_revoked_at: dispatch.capability_revoked_at ?? new Date().toISOString(), - completed_at: dispatch.completed_at ?? new Date().toISOString() + stage: 'abandoned', + // Why: an unknown start or stop's diagnostic stays readable beside the attribution. + last_error: [ + worker.last_error, + `Abandoned by ${abandonedBy ?? 'an unidentified caller'}.` + ] + .filter(Boolean) + .join(' '), + updated_at: now } }) + if (['pending', 'dispatched'].includes(dispatch.status)) { + transitionLifecycleWithDb(this.db, { + entity: 'dispatch', + id: dispatchId, + from: dispatch.status, + to: 'failed', + projection: { + last_failure: 'abandoned', + capability_revoked_at: dispatch.capability_revoked_at ?? now, + completed_at: dispatch.completed_at ?? now + } + }) + } + reconcileTaskAfterDispatchInterruption(this, dispatch.task_id, dispatchId) + this.closeQuestionsForDispatch(dispatchId) + const terminal = this.getWorkerTerminalResourceByOwner(dispatchId) + // Abandon hands an owned terminal back instead of closing it, by the same rule as worker-retain. + if (terminal?.ownership_state === 'owned') { + retainTerminalResourceInTransaction(this, terminal.id, dispatchId) + } } - reconcileTaskAfterDispatchInterruption(this, dispatch.task_id, dispatchId) - this.closeQuestionsForDispatch(dispatchId) this.db.exec('COMMIT') return { - disposition: 'abandoned', + disposition: settles + ? 'abandoned' + : worker.state === 'abandoned' + ? 'already_abandoned' + : 'already_settled', + superseded, worker: this.getWorkerDispatch(dispatchId) as WorkerDispatchRow } } catch (error) { diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts index 733833c9acab..fa826c7e1f69 100644 --- a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts @@ -1,6 +1,4 @@ -import { randomBytes } from 'node:crypto' import { OrchestrationError } from '../../orchestration-error' -import { hashDispatchCapability } from '../dispatch-capability-hash' import type { OrchestrationDb } from '../orchestration-db' import { dispatchAssigneeOrcaSessionId } from '../../dispatch-assignee-orca-session-id' @@ -21,7 +19,7 @@ export function prepareStartingWorkerAuthority( // an explicit --terminal reuse; ownership transfers only from an exact owned settled resource. terminalOwnership?: 'created' | 'external' } -): string { +): void { this.db.exec('BEGIN IMMEDIATE') try { // Why: read inside the transaction so the guarded UPDATEs below cannot lose a race with a concurrent state change. @@ -49,14 +47,13 @@ export function prepareStartingWorkerAuthority( `Terminal ${params.handle} already has an active dispatch (${existing.id} for task ${existing.task_id})` ) } - const capability = `dcap_${randomBytes(32).toString('base64url')}` const endpointId = this.getWorkerDispatch(params.dispatchId)?.runtime_epoch ?? null const contextUpdate = this.db .prepare( `UPDATE dispatch_contexts SET assignee_handle = ?, assignee_pane_key = ?, assignee_orca_session_id = ?, process_incarnation = ?, host_scope = ?, - capability_hash = ?, launch_token_hash = COALESCE(launch_token_hash, ?), + launch_token_hash = COALESCE(launch_token_hash, ?), capability_revoked_at = NULL, consumer_generation = consumer_generation + 1 WHERE id = ? AND status = 'pending'` @@ -67,7 +64,6 @@ export function prepareStartingWorkerAuthority( dispatchAssigneeOrcaSessionId(params.processIncarnation), params.processIncarnation, params.hostScope ?? null, - hashDispatchCapability(capability), params.launchTokenHash ?? null, params.dispatchId ) @@ -155,7 +151,6 @@ export function prepareStartingWorkerAuthority( } } this.db.exec('COMMIT') - return capability } catch (error) { this.db.exec('ROLLBACK') throw error @@ -168,7 +163,7 @@ export function prepareStartingWorkerAuthority( * ownership to flip, so the takeover is silently dropped and a later `worker-release` closes the * pane under the user. * - * Ownership of a pane only; the Dispatch capability stays behind the boot wait, because authority + * Ownership of a pane only; lifecycle authority stays behind the boot wait, because authority * must not be handed to a process that has not come up. */ export function recordCreatedWorkerTerminalCustody( diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-outcome.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-outcome.ts index 99873949f926..c687fdb60360 100644 --- a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-outcome.ts +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-outcome.ts @@ -44,11 +44,7 @@ export function failWorkerStart( this: OrchestrationDb, dispatchId: string, stage: string, - reason: string, - // Why (#16095): revocation exists to stop a worker acting on a dispatch that never landed. A - // prompt whose turn start went unobserved provably landed, so its worker keeps the authority its - // own report needs. - options: { retainCapability?: boolean } = {} + reason: string ): WorkerDispatchRow { this.db.exec('BEGIN IMMEDIATE') try { @@ -66,9 +62,7 @@ export function failWorkerStart( projection: { last_failure: reason, completed_at: now, - capability_revoked_at: options.retainCapability - ? dispatch.capability_revoked_at - : (dispatch.capability_revoked_at ?? now) + capability_revoked_at: dispatch.capability_revoked_at ?? now } }) transitionLifecycleWithDb(this.db, { diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-stop.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-stop.ts index fd4ee773bb4e..62ff9dba3c56 100644 --- a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-stop.ts +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-stop.ts @@ -32,6 +32,14 @@ export function isDispatchProcessCurrent( ) } +/** A `stopping` row whose stop belongs to a runtime that can no longer report its outcome. */ +export function isStopStrandedByAnotherRuntime( + worker: WorkerDispatchRow, + runtimeEpoch: string +): boolean { + return worker.state === 'stopping' && worker.runtime_epoch !== runtimeEpoch +} + export function beginWorkerStop( this: OrchestrationDb, dispatchId: string, @@ -69,9 +77,10 @@ export function beginWorkerStop( // follows claims a clean stop only from `stopping` under its own epoch // (failActiveDispatchOnExit), so it would then read the operator's stop as a crash and // escalate it. Same predicate as that reader, so both agree on whose stop this is. - const stopStrandedByAnotherRuntime = - worker.state === 'stopping' && worker.runtime_epoch !== runtimeEpoch - if (!['ready', 'start_unknown'].includes(worker.state) && !stopStrandedByAnotherRuntime) { + if ( + !['ready', 'start_unknown'].includes(worker.state) && + !isStopStrandedByAnotherRuntime(worker, runtimeEpoch) + ) { throw new OrchestrationError( 'dispatch_inactive', `Dispatch ${dispatchId} cannot stop from ${worker.state}.` diff --git a/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts b/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts index 3d45c9a86304..86657dfb0be4 100644 --- a/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts +++ b/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts @@ -7,8 +7,6 @@ import { dispatchAssigneeOrcaSessionId } from '../../dispatch-assignee-orca-sess * and release re-proves identity through it — so the custody row written at terminal creation would * name a pane no release path could match. Copy that identity across. * - * `capability_hash` stays null, so this grants nothing: it records which pane the Dispatch owns. - * * No transaction: composes inside `failWorkerStart`'s. */ export function recordFailedStartDispatchIdentity( @@ -24,7 +22,7 @@ export function recordFailedStartDispatchIdentity( `UPDATE dispatch_contexts SET assignee_handle = ?, assignee_pane_key = ?, assignee_orca_session_id = ?, process_incarnation = ?, host_scope = ? - WHERE id = ? AND status = 'failed' AND capability_hash IS NULL` + WHERE id = ? AND status = 'failed' AND process_incarnation IS NULL` ) .run( resource.terminal_handle, diff --git a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-archive.ts b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-archive.ts index 4bb4fcff8234..4f5d401b6f3b 100644 --- a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-archive.ts +++ b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-archive.ts @@ -141,6 +141,31 @@ export function revertWorkerTerminalReleaseToRetained( return this.getWorkerTerminalResource(resourceId) as WorkerTerminalResourceRow } +/** + * The one retain rule; the caller holds the transaction. A committed release (releasing, unknown) + * may already have closed the tab, so it keeps its state and the archive that preserved the output. + */ +export function retainTerminalResourceInTransaction( + database: OrchestrationDb, + resourceId: string, + dispatchId: string +): boolean { + const { changes } = database.db + .prepare( + `UPDATE worker_terminal_resources + SET release_state = 'retained', retained_reason = 'user_requested', + updated_at = datetime('now') + WHERE id = ? AND release_state IN ('not_requested', 'retained', 'requested')` + ) + .run(resourceId) + if (changes > 0) { + database.db + .prepare('DELETE FROM worker_terminal_archives WHERE dispatch_id = ?') + .run(dispatchId) + } + return changes > 0 +} + export function retainWorkerTerminalResource( this: OrchestrationDb, dispatchId: string @@ -171,20 +196,12 @@ export function retainWorkerTerminalResource( this.db.exec('COMMIT') return { disposition: 'already_released', resource } } - this.db - .prepare( - `UPDATE worker_terminal_resources - SET release_state = 'retained', retained_reason = 'user_requested', - updated_at = datetime('now') - WHERE id = ? AND release_state IN ('not_requested', 'retained', 'requested')` - ) - .run(resource.id) + const retained = retainTerminalResourceInTransaction(this, resource.id, dispatchId) const updated = this.getWorkerTerminalResource(resource.id) as WorkerTerminalResourceRow - if (updated.release_state !== 'retained') { + if (!retained) { this.db.exec('COMMIT') return { disposition: 'release_committed', resource: updated } } - this.db.prepare('DELETE FROM worker_terminal_archives WHERE dispatch_id = ?').run(dispatchId) this.db.exec('COMMIT') return { disposition: 'retained', resource: updated } } catch (error) { diff --git a/src/main/runtime/orchestration/lifecycle-caller-edges.test.ts b/src/main/runtime/orchestration/lifecycle-caller-edges.test.ts index 7f445388a257..9c2a5769bde9 100644 --- a/src/main/runtime/orchestration/lifecycle-caller-edges.test.ts +++ b/src/main/runtime/orchestration/lifecycle-caller-edges.test.ts @@ -53,6 +53,8 @@ const CALLER_EDGES: [string, string, string, string][] = [ ['worker', 'stop_unknown', 'failed', 'dispatch-completion.ts'], ['worker', 'ready', 'succeeded', 'worker-report-settlement.ts'], ['worker', 'start_unknown', 'failed', 'worker-report-settlement.ts'], + ['worker', 'stop_unknown', 'ready', 'worker-report-settlement.ts'], + ['worker', 'stop_unknown', 'failed', 'worker-report-settlement.ts'], ['worker', 'ready', 'stopping', 'worker-dispatch-stop.ts'], ['worker', 'start_unknown', 'stopping', 'worker-dispatch-stop.ts'], ['worker', 'stopping', 'stopped', 'worker-dispatch-stop.ts'], diff --git a/src/main/runtime/orchestration/lifecycle-reconciliation.test.ts b/src/main/runtime/orchestration/lifecycle-reconciliation.test.ts index 24ce2a28da66..a0ebac46379c 100644 --- a/src/main/runtime/orchestration/lifecycle-reconciliation.test.ts +++ b/src/main/runtime/orchestration/lifecycle-reconciliation.test.ts @@ -64,7 +64,7 @@ describe('lifecycle reconciliation', () => { startOptions: {} }) const paneKey = `tab_worker:${LEAF_A}` - const capability = db.prepareStartingWorkerAuthority({ + db.prepareStartingWorkerAuthority({ dispatchId: started.dispatch.id, handle: 'term_worker', paneKey, @@ -74,14 +74,6 @@ describe('lifecycle reconciliation', () => { effects: [] }) db.markWorkerStartUnknown(started.dispatch.id, 'agent_readiness', 'connection lost') - expect( - db.verifyDispatchCapability({ - dispatchId: started.dispatch.id, - capability, - paneKey, - processIncarnation: 'worker:1' - }) - ).toEqual({ valid: true }) const message = db.insertMessage({ runId: 'run_legacy_local', diff --git a/src/main/runtime/orchestration/lifecycle-reconciliation.ts b/src/main/runtime/orchestration/lifecycle-reconciliation.ts index ff6d96491fef..838d2ce03276 100644 --- a/src/main/runtime/orchestration/lifecycle-reconciliation.ts +++ b/src/main/runtime/orchestration/lifecycle-reconciliation.ts @@ -41,7 +41,6 @@ export type LifecycleReconciliationResult = export type LifecycleRejectionCode = | 'sender_not_assignee' - | 'dispatch_capability_invalid' | 'invalid_payload' | 'missing_task_id' | 'missing_dispatch_id' diff --git a/src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts b/src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts index d6e6038cd65b..c8f6963e7c18 100644 --- a/src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts +++ b/src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts @@ -28,7 +28,7 @@ describe('OrchestrationDb worker Dispatch state', () => { }) expect(d.getTask(task.id)?.status).toBe('dispatched') - const capability = d.prepareStartingWorkerAuthority({ + d.prepareStartingWorkerAuthority({ dispatchId: started.dispatch.id, handle: 'term_worker', paneKey: 'tab_worker:leaf_worker', @@ -37,7 +37,6 @@ describe('OrchestrationDb worker Dispatch state', () => { setupState: 'not_applicable', effects: [{ kind: 'terminal', action: 'created', id: 'term_worker' }] }) - expect(capability).toMatch(/^dcap_/) expect(d.markWorkerDispatchReady(started.dispatch.id)).toMatchObject({ state: 'ready', stage: 'input_accepted' @@ -298,8 +297,8 @@ describe('OrchestrationDb worker Dispatch state', () => { }) d.markWorkerDispatchReady(second.dispatch.id) - expect(d.abandonWorkerDispatch(first.dispatch.id)).toMatchObject({ - disposition: 'stale', + expect(d.abandonWorkerDispatch(first.dispatch.id, 'epoch_test')).toMatchObject({ + disposition: 'already_settled', worker: { state: 'failed' } }) expect(d.getTask(task.id)?.status).toBe('dispatched') diff --git a/src/main/runtime/orchestration/preamble.test.ts b/src/main/runtime/orchestration/preamble.test.ts index bb2087ea5afb..bede4e553143 100644 --- a/src/main/runtime/orchestration/preamble.test.ts +++ b/src/main/runtime/orchestration/preamble.test.ts @@ -83,7 +83,7 @@ describe('buildDispatchPreamble', () => { ) it('renders every injected lifecycle command on one cross-shell-safe line', () => { - const result = buildDispatchPreamble(baseParams({ dispatchCapability: 'dcap_secret' })) + const result = buildDispatchPreamble(baseParams()) const commandLines = result .split('\n') .filter((line) => line.trimStart().startsWith('orca orchestration')) @@ -182,27 +182,14 @@ describe('buildDispatchPreamble', () => { expect(cadence).toContain('immediately before\n # you send worker_done') }) - it('carries the minted Dispatch capability on lifecycle and question commands', () => { - const result = buildDispatchPreamble({ - ...baseParams(), - dispatchCapability: 'dcap_test_secret' - }) - - expect(result.match(/--dispatch-capability dcap_test_secret/g)).toHaveLength(4) - expect(result).not.toContain('"dispatchCapability"') - }) - - it('renders capability-bound worker_done and heartbeat recipes', () => { - const result = buildDispatchPreamble({ - ...baseParams(), - dispatchCapability: 'dcap_test_secret' - }) + it('renders worker_done and heartbeat recipes bound to the exact Dispatch', () => { + const result = buildDispatchPreamble(baseParams()) expect(result).toMatch( - /orchestration send --from term_worker --dispatch-capability dcap_test_secret --type worker_done .*?--task-id task_abc123 --dispatch-id ctx_def456/u + /orchestration send --from term_worker --type worker_done .*?--task-id task_abc123 --dispatch-id ctx_def456/u ) expect(result).toMatch( - /orchestration send --from term_worker --dispatch-capability dcap_test_secret --type heartbeat .*?--task-id task_abc123 --dispatch-id ctx_def456/u + /orchestration send --from term_worker --type heartbeat .*?--task-id task_abc123 --dispatch-id ctx_def456/u ) }) diff --git a/src/main/runtime/orchestration/preamble.ts b/src/main/runtime/orchestration/preamble.ts index a9fbb528939f..2502d288258b 100644 --- a/src/main/runtime/orchestration/preamble.ts +++ b/src/main/runtime/orchestration/preamble.ts @@ -10,7 +10,6 @@ export type PreambleParams = { // prevents stale messages from a previously-failed dispatch from completing // or refreshing the retry. dispatchId: string - dispatchCapability?: string taskSpec: string coordinatorHandle: string workerHandle: string @@ -57,9 +56,6 @@ export function buildDispatchPreamble(params: PreambleParams): string { cli, workerKind: params.workerKind ?? 'prompt-returning-agent' }) - const capabilityFlag = params.dispatchCapability - ? ` --dispatch-capability ${params.dispatchCapability}` - : '' // Why: one-line recipes paste unchanged in POSIX shells, PowerShell, and cmd.exe. // Why fenced: keeps the shell comments executable without rendering them as Chat UI headings. @@ -90,7 +86,7 @@ Don't post to Slack, GitHub, or other channels during the run; report through th # Never encode failure only in prose and never silently exit. # Include BOTH taskId and dispatchId in the payload so a late completion # from a failed retry cannot complete the current dispatch. - ${cli} orchestration send --from ${params.workerHandle}${capabilityFlag} --type worker_done --subject "" --body "<3-sentence summary: what you did, what you found, what's left>" --task-id ${params.taskId} --dispatch-id ${params.dispatchId} --outcome succeeded + ${cli} orchestration send --from ${params.workerHandle} --type worker_done --subject "" --body "<3-sentence summary: what you did, what you found, what's left>" --task-id ${params.taskId} --dispatch-id ${params.dispatchId} --outcome succeeded # Send a heartbeat every ${HEARTBEAT_INTERVAL_MIN} minutes # while actively working on the task. The coordinator uses this to @@ -102,7 +98,7 @@ Don't post to Slack, GitHub, or other channels during the run; report through th # attributes the heartbeat to the specific dispatch context, not just # the task, so a straggler heartbeat from a previously-failed dispatch # cannot mask a hung retry. - ${cli} orchestration send --from ${params.workerHandle}${capabilityFlag} --type heartbeat --subject "alive" --task-id ${params.taskId} --dispatch-id ${params.dispatchId} --phase "" + ${cli} orchestration send --from ${params.workerHandle} --type heartbeat --subject "alive" --task-id ${params.taskId} --dispatch-id ${params.dispatchId} --phase "" # Ask the coordinator a question and block until it answers. # @@ -114,11 +110,11 @@ Don't post to Slack, GitHub, or other channels during the run; report through th # blocks until the coordinator replies, then prints the reply body. If the # call times out or disconnects, resume with the returned message ID instead # of creating a duplicate question. - ${cli} orchestration ask --from ${params.workerHandle}${capabilityFlag} --question "" --options "" --timeout-ms 600000 + ${cli} orchestration ask --from ${params.workerHandle} --question "" --options "" --timeout-ms 600000 # Escalate a blocker or failure (pre-completion, when you need the # coordinator to do something before you can continue): - ${cli} orchestration send --from ${params.workerHandle}${capabilityFlag} --type escalation --subject "Blocked: " --body "

" --task-id ${params.taskId} --dispatch-id ${params.dispatchId} + ${cli} orchestration send --from ${params.workerHandle} --type escalation --subject "Blocked: " --body "
" --task-id ${params.taskId} --dispatch-id ${params.dispatchId} # Read coordinator follow-ups. Nothing interrupts you: a durable message only # arrives when you look, so run this at each natural checkpoint — before you diff --git a/src/main/runtime/orchestration/types.ts b/src/main/runtime/orchestration/types.ts index cd2b12a7da28..d0418289e514 100644 --- a/src/main/runtime/orchestration/types.ts +++ b/src/main/runtime/orchestration/types.ts @@ -201,6 +201,7 @@ export type RemoteDispatchAttachmentRow = { home_peer_fingerprint: string protocol_version: number runtime_epoch: string + /** Written only by hosts that minted a per-Dispatch capability; never read for authority. */ capability_hash: string | null pane_key: string | null process_incarnation: string | null @@ -285,8 +286,10 @@ export type DispatchContextRow = { assignee_pane_key: string | null /** Bare Orca session id the assignee is addressed by, when it has one (today only structured sessions); a `/clear`ed chat's lineage root. */ assignee_orca_session_id: OrcaSessionId | null + /** Written only by hosts that minted a per-Dispatch capability; never read for authority. */ capability_hash: string | null process_incarnation: string | null + /** When the Dispatch's lifecycle closed (settled, stopping, abandoned); the name predates that. */ capability_revoked_at: string | null /** Dispatch ID is the Attempt identity; retries point to the prior Attempt. */ retry_of_dispatch_id: string | null diff --git a/src/main/runtime/orchestration/worker-report-admission.ts b/src/main/runtime/orchestration/worker-report-admission.ts new file mode 100644 index 000000000000..31401d382c03 --- /dev/null +++ b/src/main/runtime/orchestration/worker-report-admission.ts @@ -0,0 +1,55 @@ +import { OrchestrationError } from './orchestration-error' +import type { WorkerDispatchState } from './types' + +// Why: an unknown start or stop is unverifiable, not exited (docs/reference/ssh-execution-boundary.md), +// so the worker's own report outranks it, locally and on a connected server. +export const UNPROVEN_WORKER_STATES = [ + 'start_unknown', + 'stop_unknown' +] as const satisfies readonly WorkerDispatchState[] + +export const SETTLEABLE_WORKER_STATES = [ + 'ready', + ...UNPROVEN_WORKER_STATES +] as const satisfies readonly WorkerDispatchState[] + +export function isWorkerStateIn( + states: readonly WorkerDispatchState[], + state: WorkerDispatchState | undefined +): boolean { + return state !== undefined && states.includes(state) +} + +export type WorkerReportRefusal = { + code: 'dispatch_inactive' | 'worker_identity_changed' + reason: string +} + +/** The one admission rule for a worker's lifecycle report or question: only a stop in flight mutes it. */ +export function workerReportRefusal(args: { + dispatchId: string + from: string + workerState: WorkerDispatchState | undefined + processCurrent: boolean +}): WorkerReportRefusal | null { + if (args.workerState === 'stopping') { + return { + code: 'dispatch_inactive', + reason: `Dispatch ${args.dispatchId} is stopping; its worker cannot report until worker-stop settles.` + } + } + if (!args.processCurrent) { + return { + code: 'worker_identity_changed', + reason: `${args.from} is not the exact process that owns Dispatch ${args.dispatchId}.` + } + } + return null +} + +export function assertWorkerCanReport(args: Parameters[0]): void { + const refusal = workerReportRefusal(args) + if (refusal) { + throw new OrchestrationError(refusal.code, refusal.reason) + } +} diff --git a/src/main/runtime/orchestration/worker-start-unobserved-prompt-settlement.test.ts b/src/main/runtime/orchestration/worker-start-unobserved-prompt-settlement.test.ts index 1d123f51b382..84291ebd8d23 100644 --- a/src/main/runtime/orchestration/worker-start-unobserved-prompt-settlement.test.ts +++ b/src/main/runtime/orchestration/worker-start-unobserved-prompt-settlement.test.ts @@ -1,11 +1,12 @@ import { afterEach, describe, expect, it } from 'vitest' import { OrchestrationDb } from './db' +import { reattachDispatchConsumer } from './db/root-dispatch-test-fixture' const WORKER_PANE_KEY = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' const INCARNATION = 'runtime_test:term_worker:1' let db: OrchestrationDb -function startWorker(spec: string): { taskId: string; dispatchId: string; capability: string } { +function startWorker(spec: string): { taskId: string; dispatchId: string } { const task = db.createTask({ runId: 'run_legacy_local', spec }) const started = db.createStartingWorkerDispatch({ creator: { kind: 'system' }, @@ -13,40 +14,27 @@ function startWorker(spec: string): { taskId: string; dispatchId: string; capabi taskId: task.id, startOptions: {} }) - const capability = db.mintDispatchCapability({ + reattachDispatchConsumer(db, { dispatchId: started.dispatch.id, paneKey: WORKER_PANE_KEY, processIncarnation: INCARNATION }) - return { taskId: task.id, dispatchId: started.dispatch.id, capability } -} - -function verify(dispatchId: string, capability: string): { valid: boolean; reason?: string } { - return db.verifyDispatchCapability({ - dispatchId, - capability, - paneKey: WORKER_PANE_KEY, - processIncarnation: INCARNATION - }) + return { taskId: task.id, dispatchId: started.dispatch.id } } describe('worker start settled by an unobserved prompt', () => { afterEach(() => db?.close()) - it('keeps the capability and lets the worker report correct the record', () => { + it('lets the worker report correct the record', () => { db = new OrchestrationDb(':memory:') - const { taskId, dispatchId, capability } = startWorker('run to completion') + const { taskId, dispatchId } = startWorker('run to completion') - db.failWorkerStart(dispatchId, 'dispatch_input', 'agent_prompt_stalled', { - retainCapability: true - }) + db.failWorkerStart(dispatchId, 'dispatch_input', 'agent_prompt_stalled') expect(db.getDispatchContextById(dispatchId)).toMatchObject({ status: 'failed', - last_failure: 'agent_prompt_stalled', - capability_revoked_at: null + last_failure: 'agent_prompt_stalled' }) - expect(verify(dispatchId, capability)).toEqual({ valid: true }) expect( db.settleWorkerReport({ @@ -61,14 +49,13 @@ describe('worker start settled by an unobserved prompt', () => { expect(db.getWorkerDispatch(dispatchId)).toMatchObject({ state: 'succeeded', stage: 'settled' }) }) - it('revokes and stays settled when the start failed for any other cause', () => { + it('stays settled when the start failed for any other cause', () => { db = new OrchestrationDb(':memory:') - const { taskId, dispatchId, capability } = startWorker('never became ready') + const { taskId, dispatchId } = startWorker('never became ready') db.failWorkerStart(dispatchId, 'agent_readiness', 'Agent did not become ready (idle).') expect(db.getDispatchContextById(dispatchId)?.capability_revoked_at).toEqual(expect.any(String)) - expect(verify(dispatchId, capability).valid).toBe(false) expect( db.settleWorkerReport({ taskId, dispatchId, outcome: 'succeeded', result: 'done' }) ).toMatchObject({ action: 'rejected', code: 'inactive_dispatch' }) @@ -79,9 +66,7 @@ describe('worker start settled by an unobserved prompt', () => { db = new OrchestrationDb(':memory:') const { taskId, dispatchId } = startWorker('reports its own failure') - db.failWorkerStart(dispatchId, 'dispatch_input', 'agent_prompt_stalled', { - retainCapability: true - }) + db.failWorkerStart(dispatchId, 'dispatch_input', 'agent_prompt_stalled') expect( db.settleWorkerReport({ taskId, dispatchId, outcome: 'failed', result: 'build broke on X' }) @@ -103,9 +88,7 @@ describe('worker start settled by an unobserved prompt', () => { it('rolls back every prompt-stall correction when the worker transition fails', () => { db = new OrchestrationDb(':memory:') const { taskId, dispatchId } = startWorker('atomic correction') - db.failWorkerStart(dispatchId, 'dispatch_input', 'agent_prompt_stalled', { - retainCapability: true - }) + db.failWorkerStart(dispatchId, 'dispatch_input', 'agent_prompt_stalled') // The worker correction is the last of the three, so aborting it must undo the other two. db.db.exec(` CREATE TRIGGER reject_worker_prompt_stall_correction @@ -125,12 +108,43 @@ describe('worker start settled by an unobserved prompt', () => { expect(db.getTask(taskId)).toMatchObject({ status: 'failed', result: null }) expect(db.getDispatchContextById(dispatchId)).toMatchObject({ status: 'failed', - last_failure: 'agent_prompt_stalled', - capability_revoked_at: null + last_failure: 'agent_prompt_stalled' }) expect(db.getWorkerDispatch(dispatchId)).toMatchObject({ state: 'failed', stage: 'dispatch_input' }) }) + + it('keeps the identity its authority attached when a stalled start fails', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ runId: 'run_legacy_local', spec: 'stalled after authority' }) + const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) + db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: 'term_worker', + paneKey: WORKER_PANE_KEY, + processIncarnation: INCARNATION, + worktreeId: 'repo::worker', + effects: [], + setupState: 'not_applicable', + terminalOwnership: 'created' + }) + // The custody row can name another pane of the same PTY (#17741); it must not win. + db.db + .prepare('UPDATE worker_terminal_resources SET pane_key = ? WHERE owner_dispatch_id = ?') + .run('tab_custody:cccccccc-cccc-4ccc-8ccc-cccccccccccc', started.dispatch.id) + + db.failWorkerStart(started.dispatch.id, 'dispatch_input', 'agent_prompt_stalled') + + expect(db.getDispatchContextById(started.dispatch.id)).toMatchObject({ + assignee_pane_key: WORKER_PANE_KEY, + process_incarnation: INCARNATION + }) + }) }) diff --git a/src/main/runtime/prime-agent-screen-readiness-transcripts.test.ts b/src/main/runtime/prime-agent-screen-readiness-transcripts.test.ts new file mode 100644 index 000000000000..e87cf6e1b653 --- /dev/null +++ b/src/main/runtime/prime-agent-screen-readiness-transcripts.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest' +import { + finalReplayFrame, + readRuntimeFixture, + replayTranscript +} from './agent-transcript-replay-test-harness' +import { isPrimeAgentComposerReadyScreen } from './prime-agent-terminal-readiness' +import { describeScreenRuledAgentTranscripts } from './screen-ruled-agent-transcript-suite' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +// prime-agent 0.9.8 on macOS, OpenRouter, isolated HOME; 0.9.5 recorded for PR #22154 (see +// each .meta.json); STA-8741. +const at120x40 = (name: string, what: string) => ({ name, what, cols: 120, rows: 40 }) +const at120x35 = (name: string, what: string) => ({ name, what, cols: 120, rows: 35 }) +const READY = [ + at120x40('prime-agent-0-9-8-ready', 'settled startup'), + at120x40('prime-agent-0-9-8-ready-after-question', 'trace question answered Not now'), + { name: 'prime-agent-0-9-8-ready-80x24', what: 'settled startup', cols: 80, rows: 24 }, + at120x40('prime-agent-0-9-8-turn-ended', 'a turn has ended'), + at120x40('prime-agent-0-9-8-tool-turn', 'a turn with a tool call has ended'), + at120x35('prime-agent-0-9-5-ready', '0.9.5 settled startup'), + at120x35('prime-agent-0-9-5-turn', '0.9.5 turn ended') +] +const NOT_READY = [ + at120x40('prime-agent-0-9-8-trace-question', 'first-launch trace-sharing question'), + at120x40('prime-agent-0-9-8-slash-menu', 'slash menu open'), + at120x40('prime-agent-0-9-8-busy-streaming', 'answer streaming'), + at120x40('prime-agent-0-9-8-draft', 'unsent text in the composer') +] + +function screenOf(lines: readonly string[]): string { + return lines.join('\n') +} + +describe('Prime Agent readiness from captured bytes', () => { + describeScreenRuledAgentTranscripts({ + agent: 'prime-agent', + foregroundProcess: 'prime-agent', + rule: isPrimeAgentComposerReadyScreen, + ready: READY, + notReady: NOT_READY, + // Why all: an idle Prime is quiet, so the quiet-process lane settles it. + readyWithoutScreen: READY.map(({ name }) => name) + }) + + // Why the footer is no ready signal (#22153): it and the bare caret stay painted mid-turn. + it('keeps the footer and the bare caret on screen mid-turn', async () => { + const { ruledScreenLines } = await finalReplayFrame('prime-agent-0-9-8-busy-streaming', 120, 40) + expect(ruledScreenLines.at(-1)?.trim().startsWith('← manage')).toBe(true) + expect(ruledScreenLines.at(-2)?.trim()).toBe('>') + expect(screenOf(ruledScreenLines)).toMatch(/[⠀-⣿] Writing/) + }) + + // Why a clocked pane waits for quiet: the rule reads these frames as ready, and only the + // spinner ticks and the question's animation keep the stream from going quiet. + it.each([ + ['prime-agent-0-9-8-busy-streaming', 'a spinner row erased before its redraw', 'Without using'], + ['prime-agent-0-9-8-trace-question', 'the composer painted before the question', null] + ])('%s: the screen alone reads ready during %s', async (name, _what, submittedMarker) => { + let readyAfterMarker = 0 + let markerSeen = submittedMarker === null + let questionSeen = false + for await (const { ruledScreenLines } of replayTranscript(readRuntimeFixture(name), 120, 40)) { + const screen = screenOf(ruledScreenLines) + markerSeen ||= submittedMarker !== null && screen.includes(submittedMarker) + questionSeen ||= screen.includes('Share agent traces') + if (markerSeen && !questionSeen && isPrimeAgentComposerReadyScreen(ruledScreenLines)) { + readyAfterMarker += 1 + } + } + expect(readyAfterMarker).toBeGreaterThan(0) + }) +}) diff --git a/src/main/runtime/prime-agent-terminal-readiness.ts b/src/main/runtime/prime-agent-terminal-readiness.ts new file mode 100644 index 000000000000..3ca6d916f579 --- /dev/null +++ b/src/main/runtime/prime-agent-terminal-readiness.ts @@ -0,0 +1,22 @@ +const PRIME_FOOTER_PREFIX = '← manage' +// 0.9.8 paints `Details mode`, 0.9.5 `Collapsed mode`. +const PRIME_VIEW_MODE_HINT_RE = /^\S+ mode \(ctrl\+o to expand\)$/i +const BRAILLE_SPINNER_RE = /[⠀-⣿]/ + +/** + * Prime Agent 0.9.5+ at an idle composer: a bare `>` over the `← manage` footer. + * Why the spinner veto: the footer and the caret both stay painted for a whole turn; only the + * status row Prime keeps directly above them (`⠦ Writing · 6s`) says a turn is running. + */ +export function isPrimeAgentComposerReadyScreen(screenLines: readonly string[]): boolean { + const footer = screenLines.at(-1)?.trim() ?? '' + const composer = screenLines.at(-2)?.trim() + if (!footer.startsWith(PRIME_FOOTER_PREFIX) || composer !== '>') { + return false + } + let statusIndex = screenLines.length - 3 + if (PRIME_VIEW_MODE_HINT_RE.test(screenLines[statusIndex]?.trim() ?? '')) { + statusIndex -= 1 + } + return !BRAILLE_SPINNER_RE.test(screenLines[statusIndex] ?? '') +} diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 1ec159337827..1402f10b2231 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -47,7 +47,6 @@ export type RpcRequest = { authToken: string method: string params?: unknown - orchestrationCapability?: string orchestrationContractVersion?: number orchestrationRequestId?: string compatibilityInvocationId?: string @@ -82,8 +81,6 @@ export type RpcContext = { clientCapabilities?: readonly RuntimeCapability[] // Why: mobile v2 auth is exact-key validated; capability upgrades must mutate only the authenticated socket after auth. updateClientCapabilities?: (capabilities: readonly RuntimeCapability[]) => void - // Why: Dispatch authority rides in the authenticated RPC envelope, never in user payload fields. - orchestrationCapability?: string // Why: long-lived mutations such as ask can durably expose acceptance before their waiter settles. recordMutationReceipt?: (receipt: unknown) => void // Why: only local worker_done makes pending proof that its atomic settlement transaction never committed. diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 83cd0e30f9c9..04600fcc4ecd 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -119,7 +119,6 @@ export class RpcDispatcher { clientKind: options?.clientKind, clientCapabilities: options?.clientCapabilities, updateClientCapabilities: options?.updateClientCapabilities, - orchestrationCapability: request.orchestrationCapability, authenticatedCallerFingerprint: options?.authenticatedCallerFingerprint, orchestrationCaller: resolved.caller }, diff --git a/src/main/runtime/rpc/e2ee-channel.test.ts b/src/main/runtime/rpc/e2ee-channel.test.ts index 8602263400d1..f4a09397d9a3 100644 --- a/src/main/runtime/rpc/e2ee-channel.test.ts +++ b/src/main/runtime/rpc/e2ee-channel.test.ts @@ -388,19 +388,6 @@ describe('E2EEChannel', () => { }) }) - describe('cross-compatibility', () => { - it('desktop encrypt is decryptable by desktop decrypt (sanity)', () => { - const a = generateKeyPair() - const b = generateKeyPair() - const sharedA = deriveSharedKey(a.secretKey, b.publicKey) - const sharedB = deriveSharedKey(b.secretKey, a.publicKey) - - const msg = '{"method":"terminal.subscribe","params":{"terminal":"t1"}}' - const enc = encrypt(msg, sharedA) - expect(decrypt(enc, sharedB)).toBe(msg) - }) - }) - describe('destroy', () => { it('clears state and stops forwarding', () => { const ctx = setup() diff --git a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts index bf90e650d534..24d0a80b3d1d 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts @@ -7,7 +7,7 @@ * harness rather than of the guard. */ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -26,9 +26,9 @@ import { } from '../../../../shared/agent-session-operation-ledger' import type { AgentSessionRecordStore } from '../../agent-session-record-store' import { + editPersistedTestAgentSessionStore, openTestAgentSessionRecordStore, - readPersistedTestAgentSessionStore, - testAgentSessionStoreFilePath + readPersistedTestAgentSessionStore } from '../../agent-session-record-store-test-harness' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' @@ -420,10 +420,8 @@ describe('the recorded row stays readable by a build that predates it', () => { const outcome = Object.values(file.operations)[0].outcome // The ratchet, and the reason this is not a new status arm or an optional `sessionId`: a build - // without `launch` validates a row by these two fields, one row it rejects returns null for the - // whole file, and the schema version cannot be bumped to excuse it — a store is unreadable to - // any build whose version is higher than the file's. A downgrade must skip what it cannot - // understand, not lose every lease. + // without `launch` validates a row by these two fields and drops a row it rejects, losing its + // replay. A downgrade must skip only what it cannot understand. expect(outcome.status).toBe('succeeded') expect(typeof outcome.sessionId).toBe('string') expect(outcome.launch).toMatchObject({ outcome: { kind: 'terminal' } }) @@ -431,24 +429,17 @@ describe('the recorded row stays readable by a build that predates it', () => { }) describe('an unreadable launch payload costs one replay, never the store', () => { - /** The whole file, primary and backup: `loadAgentSessionStore` falls through to the backup, and - * the backup is a copy of the validated primary, so both carry the same payload in real life. */ + /** The recorded operation row, as a build that wrote another payload shape leaves it. */ async function rewriteRecordedLaunch(payload: unknown): Promise { - const path = testAgentSessionStoreFilePath(directory) - const file: { operations: Record }> } = JSON.parse( - await readFile(path, 'utf-8') - ) - const row = Object.values(file.operations)[0] - row.outcome.launch = payload - const written = JSON.stringify(file) - await writeFile(path, written) - await writeFile(`${path}.bak`, written) + await editPersistedTestAgentSessionStore(directory, (persisted) => { + const row: { outcome: Record } = Object.values(persisted.operations)[0] + row.outcome.launch = payload + }) } - it('still admits the row, because one rejected row makes the whole file unparseable', () => { + it('still admits the row, because a load drops a row it rejects', () => { // The ratchet. `isAgentLaunchResult` mirrors a result type by hand, so a field tightened there - // would reject rows this same build wrote — and a primary and backup that both fail to parse - // raise `agent_session_store_corrupt`, taking every lease in the profile with them. + // would reject rows this same build wrote and lose their replay. expect( isAgentSessionOperationRow({ callerKey: 'device-1', diff --git a/src/main/runtime/rpc/methods/files-open-navigation.test.ts b/src/main/runtime/rpc/methods/files-open-navigation.test.ts new file mode 100644 index 000000000000..0d3ee7b1c0dd --- /dev/null +++ b/src/main/runtime/rpc/methods/files-open-navigation.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { FILE_METHODS } from './files' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +function createDispatcher(): { + runtime: Pick + dispatcher: RpcDispatcher +} { + const opened = { worktree: 'wt-1', relativePath: 'a.ts', kind: 'text', opened: true } + const runtime = { + getRuntimeId: () => 'test-runtime', + openMobileFile: vi.fn().mockResolvedValue(opened), + openMobileDiff: vi.fn().mockResolvedValue(opened) + } + const dispatcher = new RpcDispatcher({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FILE_METHODS open handlers only read the stubbed members. + runtime: runtime as unknown as OrcaRuntimeService, + methods: FILE_METHODS + }) + return { runtime, dispatcher } +} + +describe('file open RPC navigation', () => { + it('passes an explicit navigation target through file and diff opens', async () => { + const { runtime, dispatcher } = createDispatcher() + + await dispatcher.dispatch( + makeRequest('files.open', { worktree: 'id:wt-1', relativePath: 'a.ts', navigation: 'all' }) + ) + await dispatcher.dispatch( + makeRequest('files.openDiff', { + worktree: 'id:wt-1', + relativePath: 'a.ts', + navigation: 'host' + }) + ) + + expect(runtime.openMobileFile).toHaveBeenCalledWith('id:wt-1', 'a.ts', 'all') + expect(runtime.openMobileDiff).toHaveBeenCalledWith('id:wt-1', 'a.ts', false, 'host') + }) + + it('leaves navigation absent for callers that send none (phones, older CLIs)', async () => { + const { runtime, dispatcher } = createDispatcher() + + await dispatcher.dispatch( + makeRequest('files.open', { worktree: 'id:wt-1', relativePath: 'a.ts' }) + ) + await dispatcher.dispatch( + makeRequest('files.openDiff', { worktree: 'id:wt-1', relativePath: 'a.ts', staged: true }) + ) + + expect(runtime.openMobileFile).toHaveBeenCalledWith('id:wt-1', 'a.ts', undefined) + expect(runtime.openMobileDiff).toHaveBeenCalledWith('id:wt-1', 'a.ts', true, undefined) + }) + + it('rejects an unknown navigation target on file opens', async () => { + const { runtime, dispatcher } = createDispatcher() + + const response = await dispatcher.dispatch( + makeRequest('files.open', { + worktree: 'id:wt-1', + relativePath: 'a.ts', + navigation: 'everyone' + }) + ) + + expect(response).toMatchObject({ ok: false }) + expect(runtime.openMobileFile).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/files.test.ts b/src/main/runtime/rpc/methods/files.test.ts index 114d4e75df8d..2b29e9b65b64 100644 --- a/src/main/runtime/rpc/methods/files.test.ts +++ b/src/main/runtime/rpc/methods/files.test.ts @@ -51,7 +51,7 @@ describe('file RPC methods', () => { makeRequest('files.open', { worktree: 'id:wt-1', relativePath: 'docs/readme.md' }) ) - expect(runtime.openMobileFile).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md') + expect(runtime.openMobileFile).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md', undefined) expect(response).toMatchObject({ ok: true, result: { kind: 'markdown', opened: true } @@ -78,7 +78,12 @@ describe('file RPC methods', () => { }) ) - expect(runtime.openMobileDiff).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md', true) + expect(runtime.openMobileDiff).toHaveBeenCalledWith( + 'id:wt-1', + 'docs/readme.md', + true, + undefined + ) expect(response).toMatchObject({ ok: true, result: { kind: 'markdown', opened: true } diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index 29e2f7b071f8..15ddd47b335f 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -11,6 +11,7 @@ import { DocPreviewFileRead, FileListAll, FileOpenDiff, + FileOpenTab, FilePathSearch, FileReadChunk, FileSearch, @@ -56,15 +57,20 @@ export const FILE_METHODS = [ }), defineMethod({ name: 'files.open', - params: FileOpen, + params: FileOpenTab, handler: async (params, { runtime }) => - runtime.openMobileFile(params.worktree, params.relativePath) + runtime.openMobileFile(params.worktree, params.relativePath, params.navigation) }), defineMethod({ name: 'files.openDiff', params: FileOpenDiff, handler: async (params, { runtime }) => - runtime.openMobileDiff(params.worktree, params.relativePath, params.staged === true) + runtime.openMobileDiff( + params.worktree, + params.relativePath, + params.staged === true, + params.navigation + ) }), defineMethod({ name: 'files.read', diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts index f5684f1ba1b7..a5a79b6be153 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -188,7 +188,7 @@ export async function discardStructuredWorkerSession( } try { await host.setSessionTabVisibility?.(sessionId, false) - await host.close(sessionId) + await host.close(sessionId, 'evict') } catch (error) { console.warn( '[orchestration] failed to discard a half-started structured worker', diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts index 98a843fb695b..911d620c3a82 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts @@ -104,13 +104,14 @@ function fakes() { getTerminalPaneKey: vi.fn(() => 'pane_1'), retireStructuredAgentSessionTabFromSnapshot } as unknown as OrcaRuntimeService + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the start path calls only these three methods. const db = { createStartingWorkerDispatch: () => ({ dispatch: { id: 'd_fail', depth: 0 }, task: { id: 't1', spec: 'do the thing' } }), recordWorkerStage: () => {}, - prepareStartingWorkerAuthority: () => 'capability' + prepareStartingWorkerAuthority: () => {} } as unknown as OrchestrationDb return { runtime, db, retireStructuredAgentSessionTabFromSnapshot } } diff --git a/src/main/runtime/rpc/methods/orchestration-worker-release-reap-fixed.func.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-release-reap-fixed.func.test.ts deleted file mode 100644 index 579f37eaf9e3..000000000000 --- a/src/main/runtime/rpc/methods/orchestration-worker-release-reap-fixed.func.test.ts +++ /dev/null @@ -1,358 +0,0 @@ -// PRB-0219 / upstream #18737 — FUNCTIONAL (integration) reproduction of the steady-state -// worker-release reap LEAK. -// -// This is the "functional/integration" tier of the PRB-0219 test strategy. It wires the REAL -// orchestration RPC surface (orchestration.workerStart / workerRelease / workerList), the REAL -// OrchestrationDb, and the REAL release completion + observation modules against a fake runtime -// that faithfully models the two identity planes involved in the bug: -// -// * VOLATILE, epoch-fenced handle table — `handleTable` keyed by terminal handle, each entry -// stamped with the `rendererGraphEpoch` at which it was issued. `showTerminal` resolves a -// handle ONLY while its stamped epoch matches the current epoch (mirrors getLiveLeafForHandle -// throwing 'terminal_handle_stale' on a rendererGraphEpoch bump). A relay reconnect / renderer -// remount on headless serve bumps the epoch. -// * DURABLE, incarnation-addressed process table — `ptysById` keyed by the ptyId embedded in the -// worker's persisted process_incarnation (`${ptyId}:${incarnationId}`). The pty stays LIVE -// across an epoch bump; nothing about the graph epoch kills the process. -// -// The bug (mechanism): when the epoch bumps, the durable db handle stops resolving through -// showTerminal WHILE THE PTY IS STILL ALIVE. inspectWorkerTerminal swallows the throw and reports -// `missing`; completeWorkerTerminalRelease then commits `release_unknown` and returns WITHOUT ever -// calling runtime.closeTerminal — so the process/PTY leaks. On mtl-02 those orphans accumulate in -// the orca-serve@factory cgroup until TasksMax=4096 is hit and Bun/omp abort() on EAGAIN. -// -// The observable leak signal asserted here: state 'release_unknown' + processAction 'none' + -// closeTerminal NEVER called + the pty STILL alive in ptysById + worker-list terminalState stuck -// on 'release_unknown' (never 'released'). - -import { afterEach, describe, expect, it, vi } from 'vitest' -import { ORCHESTRATION_METHODS } from './orchestration' -import { eraseRpcMethods, type RpcContext } from '../core' -import { OrchestrationDb } from '../../orchestration/db' -import { OrcaRuntimeService } from '../../orca-runtime' - -describe('PRB-0219 worker-release reap FIX (functional verification)', () => { - let db: OrchestrationDb - let dbOpen = false - let runtime: OrcaRuntimeService - let ctx: RpcContext - let activeRunId: string - - const coordinatorPaneKey = 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' - const workerPaneKey = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' - - // Dispatch/automation ptyId shape: `${repoId}::${worktreePath}@@${suffix}` (executionHostId:null). - const PTY_ID = 'repo-7f3a::/data/wt/factory-task-1@@a1b2c3d4' - const INCARNATION_ID = 1 - const PROCESS_INCARNATION = `${PTY_ID}:${INCARNATION_ID}` - - // ---- fake runtime process/handle planes (module-level so spies can mutate them) ---- - let ptysById: Map - let handleTable: Map - let rendererGraphEpoch: number - let closedPtyIds: string[] - - /** Resolve a handle to its live pty only while its stamped graph epoch is current. */ - function resolveHandleToLivePty( - handle: string - ): { ptyId: string; pty: { incarnationId: number; alive: boolean } } | null { - const entry = handleTable.get(handle) - if (!entry) { - return null - } - if (entry.epoch !== rendererGraphEpoch) { - // rendererGraphEpoch fence: the durable handle no longer resolves to a live leaf. - return null - } - const pty = ptysById.get(entry.ptyId) - if (!pty || !pty.alive) { - return null - } - return { ptyId: entry.ptyId, pty } - } - - /** Wire the real RPC surface and db against the two-plane fake runtime. */ - function setup(): void { - ptysById = new Map([ - ['coord-pty', { incarnationId: 1, alive: true }], - [PTY_ID, { incarnationId: INCARNATION_ID, alive: true }] - ]) - handleTable = new Map([ - ['term_coord', { ptyId: 'coord-pty', epoch: 0 }], - ['term_worker', { ptyId: PTY_ID, epoch: 0 }] - ]) - rendererGraphEpoch = 0 - closedPtyIds = [] - - db = new OrchestrationDb(':memory:') - dbOpen = true - runtime = new OrcaRuntimeService() - runtime.setOrchestrationDb(db) - - // Incarnation-addressed liveness probe: reads the DURABLE plane, so it stays 'live' across the - // epoch bump (the process really is still running). Matches the real asymmetry. - vi.spyOn(runtime, 'inspectTerminalProcessIncarnationLiveness').mockImplementation( - async (incarnation: string) => { - const idx = incarnation.lastIndexOf(':') - const ptyId = incarnation.slice(0, idx) - const pty = ptysById.get(ptyId) - return pty?.alive ? 'live' : 'exited' - } - ) - - // The incarnation-addressed re-resolution primitive the fix adds. Present here as a spy so the - // same harness proves BOTH tiers: pre-fix completion never calls it (leak); post-fix completion - // calls it to remint a live handle (reap). Fence: EXACT incarnationId match only. - vi.spyOn(runtime, 'resolveTerminalHandleByProcessIncarnation').mockImplementation( - (incarnation: string): string | null => { - const idx = incarnation.lastIndexOf(':') - const ptyId = incarnation.slice(0, idx) - const inc = Number(incarnation.slice(idx + 1)) - const pty = ptysById.get(ptyId) - if (!pty || !pty.alive) { - return null - } - if (pty.incarnationId !== inc) { - // Fence: a reused ptyId with a different incarnation must NOT resolve. - return null - } - // Remint a live handle at the current graph epoch. - handleTable.set('term_reminted', { ptyId, epoch: rendererGraphEpoch }) - return 'term_reminted' - } - ) - - // Identity plane (durable) — answers for the original AND any reminted handle. Independent of - // the graph epoch, exactly like the real getTerminal* accessors that read dispatch authority. - const knownWorkerHandle = (handle: string): boolean => - handle === 'term_worker' || handle === 'term_reminted' - vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => - handle === 'term_coord' - ? coordinatorPaneKey - : knownWorkerHandle(handle) - ? workerPaneKey - : null - ) - vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockImplementation((handle) => - knownWorkerHandle(handle) ? PROCESS_INCARNATION : null - ) - vi.spyOn(runtime, 'getOrchestrationDispatchAuthority').mockImplementation((handle) => - knownWorkerHandle(handle) - ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test fixture is deliberately shaped to exercise the private/runtime boundary. - ({ - terminalHandle: handle, - paneKey: workerPaneKey, - processIncarnation: PROCESS_INCARNATION, - hostScope: { kind: 'local', hostId: 'local' } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. - } as never) - : null - ) - vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) - - // Volatile handle resolution — epoch-fenced. Throws 'terminal_handle_stale' once the epoch - // moves past the epoch at which the handle was issued. - vi.spyOn(runtime, 'showTerminal').mockImplementation(async (handle) => { - if (!resolveHandleToLivePty(handle)) { - throw new Error('terminal_handle_stale') - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. - return { handle, worktreeId: 'repo::worktree', status: 'running' } as never - }) - - // The reap: closing a handle kills exactly the pty it resolves to. - vi.spyOn(runtime, 'closeTerminal').mockImplementation(async (handle) => { - const live = resolveHandleToLivePty(handle) - if (!live) { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. - return { handle, tabId: null, ptyKilled: false } as never - } - live.pty.alive = false - ptysById.delete(live.ptyId) - closedPtyIds.push(live.ptyId) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. - return { handle, tabId: `tab:${live.ptyId}`, ptyKilled: true } as never - }) - - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. - // Remaining runtime surface required to start + settle a worker (mirrors the unit harness). - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. - vi.spyOn(runtime, 'showManagedTerminalWorkspace').mockResolvedValue({ - id: 'repo::worktree' - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. - } as never) - vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ - handle: 'term_worker', - worktreeId: 'repo::worktree', - title: 'worker' - }) - vi.spyOn(runtime, 'waitForTerminal').mockResolvedValue({ - handle: 'term_worker', - condition: 'tui-idle', - satisfied: true, - status: 'running', - exitCode: null - }) - vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca') - vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({ - handle: 'term_worker', - accepted: true, - bytesWritten: 1 - }) - vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) - vi.spyOn(runtime, 'getExactWorkerProviderSession').mockReturnValue(null) - vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ - handle: 'term_worker', - status: 'running', - tail: ['worker output line 1', 'worker output line 2'], - truncated: false, - nextCursor: '2' - }) - vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) - - activeRunId = db.createRun({ - objective: 'PRB-0219 reap leak fixture', - coordinatorHandle: 'term_coord', - coordinatorPaneKey - }).id - ctx = { runtime } - } - - afterEach(() => { - if (dbOpen) { - dbOpen = false - db.close() - } - vi.restoreAllMocks() - }) - - /** Look up a registered orchestration RPC method by name. */ - function findMethod(name: string) { - const method = eraseRpcMethods(ORCHESTRATION_METHODS).find((m) => m.name === name) - if (!method) { - throw new Error(`Method not found: ${name}`) - } - return method - } - - /** Parse a method's params and invoke its handler against the shared ctx. */ - async function call(name: string, params: Record) { - const method = findMethod(name) - const parsed = method.params ? method.params.parse(params) : undefined - return method.handler(parsed, ctx) - } - - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. - /** Start a worker and settle its report, the state a release acts on. */ - async function startSettledWorker(): Promise<{ taskId: string; dispatchId: string }> { - const task = db.createTask({ spec: 'reap-leak fixture task', runId: activeRunId }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. - const result = (await call('orchestration.workerStart', { - task: task.id, - from: 'term_coord', - agent: 'codex' - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. - })) as { dispatchId: string; state: string } - expect(result.state).toBe('ready') - const settlement = db.settleWorkerReport({ - taskId: task.id, - dispatchId: result.dispatchId, - outcome: 'succeeded', - result: 'worker succeeded' - }) - expect(settlement.action).toBe('settled') - return { taskId: task.id, dispatchId: result.dispatchId } - } - - /** The terminalState workerList projects for a dispatch, or null. */ - async function workerTerminalState(dispatchId: string): Promise { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. - const listed = (await call('orchestration.workerList', { run: activeRunId })) as { - workers: { dispatchId: string; terminalState: string | null }[] - } - return listed.workers.find((w) => w.dispatchId === dispatchId)?.terminalState ?? null - } - - it('REAP (fixed): a rendererGraphEpoch bump strands the durable handle, but the incarnation fallback remints a live handle and reaps the process', async () => { - setup() - const { dispatchId } = await startSettledWorker() - const resource = db.getWorkerTerminalResourceByOwner(dispatchId) - expect(resource?.process_incarnation).toBe(PROCESS_INCARNATION) - - // Relay reconnect / renderer remount bumps the graph epoch: the durable handle goes stale - // while the PTY stays alive. - rendererGraphEpoch = 1 - await expect(runtime.showTerminal('term_worker')).rejects.toThrow('terminal_handle_stale') - expect(ptysById.get(PTY_ID)?.alive).toBe(true) - - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. - const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { - state: string - processAction: string - } - - // The fix: inspectWorkerTerminal re-resolved the live PTY by process incarnation, reminted a - // handle, and completion closed THAT handle — the process is actually reaped. - expect(receipt.state).toBe('released') - expect(receipt.processAction).toBe('closed_agent_terminal') - expect(runtime.closeTerminal).toHaveBeenCalledWith('term_reminted') - expect(closedPtyIds).toEqual([PTY_ID]) - expect(ptysById.has(PTY_ID)).toBe(false) - expect(await workerTerminalState(dispatchId)).toBe('released') - }) - - it('CONTROL: with the graph epoch intact the same release reaps exactly that PTY', async () => { - setup() - const { dispatchId } = await startSettledWorker() - - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. - const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { - state: string - processAction: string - } - - expect(receipt.state).toBe('released') - expect(receipt.processAction).toBe('closed_agent_terminal') - expect(closedPtyIds).toEqual([PTY_ID]) - expect(ptysById.has(PTY_ID)).toBe(false) - expect(await workerTerminalState(dispatchId)).toBe('released') - }) - - it('FENCE (fixed): a reused ptyId carrying a different incarnation must NOT remint or close — stays release_unknown', async () => { - setup() - const { dispatchId } = await startSettledWorker() - - // The graph epoch bumps AND the ptyId has been reused by a different process (incarnation 2), - // while the worker's recorded incarnation is still 1. The exact-incarnation fence must refuse. - rendererGraphEpoch = 1 - const reused = ptysById.get(PTY_ID) - if (reused) { - reused.incarnationId = 2 - } - - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. - const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { - state: string - processAction: string - } - - expect(receipt.state).toBe('release_unknown') - expect(receipt.processAction).toBe('none') - expect(runtime.closeTerminal).not.toHaveBeenCalled() - // The other lane's live process is left untouched (never reaped by an over-broad match). - expect(ptysById.get(PTY_ID)?.alive).toBe(true) - expect(await workerTerminalState(dispatchId)).toBe('release_unknown') - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federated-message-targeting.test.ts b/src/main/runtime/rpc/methods/orchestration/federation/federated-message-targeting.test.ts index c28ef94a4a98..6d3f00abd6a0 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federated-message-targeting.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federated-message-targeting.test.ts @@ -38,7 +38,7 @@ describe('orchestration federated message targeting', () => { payloadHash: 'attach_payload' } }) - const capability = db.prepareRemoteAttachmentAuthority({ + db.prepareRemoteAttachmentAuthority({ dispatchId, paneKey, processIncarnation, @@ -50,22 +50,22 @@ describe('orchestration federated message targeting', () => { db.markRemoteAttachmentReady(dispatchId) const dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) const requests: RpcRequest[] = [ - request('send_to', capability, 'orchestration.send', { + request('send_to', 'orchestration.send', { from: 'term_remote_worker', to: 'run:explicit', subject: 'Wrong explicit target' }), - request('send_run', capability, 'orchestration.send', { + request('send_run', 'orchestration.send', { from: 'term_remote_worker', run: 'run_explicit', subject: 'Wrong explicit Run' }), - request('ask_to', capability, 'orchestration.ask', { + request('ask_to', 'orchestration.ask', { from: 'term_remote_worker', to: 'run:explicit', question: 'Wrong explicit target?' }), - request('ask_run', capability, 'orchestration.ask', { + request('ask_run', 'orchestration.ask', { from: 'term_remote_worker', run: 'run_explicit', question: 'Wrong explicit Run?' @@ -89,7 +89,6 @@ describe('orchestration federated message targeting', () => { function request( id: string, - capability: string, method: 'orchestration.send' | 'orchestration.ask', params: Record ): RpcRequest { @@ -98,7 +97,6 @@ function request( authToken: 'worker-token', orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, orchestrationRequestId: `request_${id}`, - orchestrationCapability: capability, method, params } diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts index e111865d9041..94341ac9c6fe 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts @@ -56,15 +56,14 @@ describe('orchestration federation lifecycle settlement', () => { failNextAckBeforeDelivery = false throw new Error('connection lost before acknowledgment') } - return (await workerDispatcher.dispatch({ + return await workerDispatcher.dispatch({ id: `remote_${method}`, authToken: 'run-home-device-token', method, params, orchestrationContractVersion: envelope?.orchestrationContractVersion, - orchestrationRequestId: envelope?.orchestrationRequestId, - orchestrationCapability: envelope?.orchestrationCapability - })) as RuntimeRpcResponse + orchestrationRequestId: envelope?.orchestrationRequestId + }) } } homeRuntime = new OrcaRuntimeService(null, undefined, { @@ -155,14 +154,11 @@ describe('orchestration federation lifecycle settlement', () => { await homeDispatcher.dispatch(startRequest(taskId)) homeRuntime.stopOrchestrationFederationRelay() const dispatch = homeDb.getDispatchContext(taskId)! - const prompt = vi.mocked(workerRuntime.sendTerminalAgentPrompt).mock.calls[0]?.[1] ?? '' - const capability = prompt.match(/--dispatch-capability (dcap_[A-Za-z0-9_-]+)/)?.[1] const sent = workerDispatcher.dispatch({ id: 'rpc_waiting_worker_done', authToken: 'worker-local-token', orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, orchestrationRequestId: 'waiting_worker_done_request', - orchestrationCapability: capability, method: 'orchestration.send', params: { from: 'term_windows_worker', @@ -192,15 +188,12 @@ describe('orchestration federation lifecycle settlement', () => { signal?: AbortSignal, outcome: 'succeeded' | 'failed' = 'succeeded' ) { - const prompt = vi.mocked(workerRuntime.sendTerminalAgentPrompt).mock.calls[0]?.[1] ?? '' - const capability = prompt.match(/--dispatch-capability (dcap_[A-Za-z0-9_-]+)/)?.[1] return workerDispatcher.dispatch( { id: `rpc_${requestId}`, authToken: 'worker-local-token', orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, orchestrationRequestId: requestId, - orchestrationCapability: capability, method: 'orchestration.send', params: { from: 'term_windows_worker', @@ -458,7 +451,7 @@ describe('orchestration federation lifecycle settlement', () => { payloadHash: `persisted_protocol_${protocolVersion}_payload` } }) - const capability = workerDb.prepareRemoteAttachmentAuthority({ + workerDb.prepareRemoteAttachmentAuthority({ dispatchId, paneKey: 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', processIncarnation: 'windows_runtime:pty:1', @@ -475,7 +468,6 @@ describe('orchestration federation lifecycle settlement', () => { authToken: 'worker-local-token', orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, orchestrationRequestId: `persisted_protocol_${protocolVersion}_completion_request`, - orchestrationCapability: capability, method: 'orchestration.send', params: { from: 'term_windows_worker', @@ -662,8 +654,6 @@ describe('orchestration federation lifecycle settlement', () => { await homeDispatcher.dispatch(startRequest(task.id)) homeRuntime.stopOrchestrationFederationRelay() const dispatch = homeDb.getDispatchContext(task.id)! - const prompt = vi.mocked(workerRuntime.sendTerminalAgentPrompt).mock.calls[0]?.[1] ?? '' - const capability = prompt.match(/--dispatch-capability (dcap_[A-Za-z0-9_-]+)/)?.[1] const controller = new AbortController() const sent = workerDispatcher.dispatch( { @@ -671,7 +661,6 @@ describe('orchestration federation lifecycle settlement', () => { authToken: 'worker-local-token', orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, orchestrationRequestId: 'aborted_worker_done_request', - orchestrationCapability: capability, method: 'orchestration.send', params: { from: 'term_windows_worker', diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-output.test.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-output.test.ts index 3abaa36a62f5..cb298ad873f5 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation-output.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-output.test.ts @@ -3,7 +3,6 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { RuntimeRpcResponse } from '../../../../../../shared/runtime-rpc-envelope' import { ORCHESTRATION_CONTRACT_VERSION, ORCHESTRATION_FEDERATION_FLEET_SNAPSHOT_RUNTIME_CAPABILITY, @@ -119,15 +118,14 @@ describe('orchestration federated worker output', () => { error: { code: 'relay_provider_unavailable', message: 'relay unavailable' } } } - return (await workerDispatcher.dispatch({ + return await workerDispatcher.dispatch({ id: `remote_${method}`, authToken: 'run-home-device-token', method, params, orchestrationContractVersion: envelope?.orchestrationContractVersion, - orchestrationRequestId: envelope?.orchestrationRequestId, - orchestrationCapability: envelope?.orchestrationCapability - })) as RuntimeRpcResponse + orchestrationRequestId: envelope?.orchestrationRequestId + }) } } homeRuntime = new OrcaRuntimeService(null, undefined, { diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts index 9c71da963d45..450f9ff9a5fb 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { RuntimeRpcResponse } from '../../../../../../shared/runtime-rpc-envelope' import { ORCHESTRATION_CONTRACT_VERSION, ORCHESTRATION_FEDERATION_CONTROL_MAIL_RUNTIME_CAPABILITY @@ -55,15 +54,14 @@ describe('orchestration federation', () => { _meta: { runtimeId: workerRuntime.getRuntimeId() } } } - const response = (await workerDispatcher.dispatch({ + const response = await workerDispatcher.dispatch({ id: `remote_${method}`, authToken: 'run-home-device-token', method, params, orchestrationContractVersion: envelope?.orchestrationContractVersion, - orchestrationRequestId: envelope?.orchestrationRequestId, - orchestrationCapability: envelope?.orchestrationCapability - })) as RuntimeRpcResponse + orchestrationRequestId: envelope?.orchestrationRequestId + }) if (method === 'orchestration.federationAck' && loseNextAckResponse) { loseNextAckResponse = false throw new Error('connection lost after acknowledgment') @@ -281,16 +279,12 @@ describe('orchestration federation', () => { const started = await homeDispatcher.dispatch(startRequest(task.id)) expect(started.ok).toBe(true) const dispatch = homeDb.getDispatchContext(task.id)! - const prompt = vi.mocked(workerRuntime.sendTerminalAgentPrompt).mock.calls[0]?.[1] ?? '' - const capability = prompt.match(/--dispatch-capability (dcap_[A-Za-z0-9_-]+)/)?.[1] - expect(capability).toBeTruthy() const sent = await workerDispatcher.dispatch({ id: 'rpc_worker_done', authToken: 'worker-local-token', orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, orchestrationRequestId: 'worker_done_request', - orchestrationCapability: capability, method: 'orchestration.send', params: { from: 'term_windows_worker', @@ -334,14 +328,11 @@ describe('orchestration federation', () => { const task = createHomeTask() await homeDispatcher.dispatch(startRequest(task.id)) const dispatch = homeDb.getDispatchContext(task.id)! - const prompt = vi.mocked(workerRuntime.sendTerminalAgentPrompt).mock.calls[0]?.[1] ?? '' - const capability = prompt.match(/--dispatch-capability (dcap_[A-Za-z0-9_-]+)/)?.[1] const ask = workerDispatcher.dispatch({ id: 'rpc_remote_ask', authToken: 'worker-local-token', orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, orchestrationRequestId: 'remote_question_request', - orchestrationCapability: capability, method: 'orchestration.ask', params: { from: 'term_windows_worker', @@ -403,14 +394,11 @@ describe('orchestration federation', () => { it('keeps a timed-out remote question resumable', async () => { const task = createHomeTask() await homeDispatcher.dispatch(startRequest(task.id)) - const prompt = vi.mocked(workerRuntime.sendTerminalAgentPrompt).mock.calls[0]?.[1] ?? '' - const capability = prompt.match(/--dispatch-capability (dcap_[A-Za-z0-9_-]+)/)?.[1] const timedOut = await workerDispatcher.dispatch({ id: 'rpc_remote_ask_timeout', authToken: 'worker-local-token', orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, orchestrationRequestId: 'remote_question_timeout_request', - orchestrationCapability: capability, method: 'orchestration.ask', params: { from: 'term_windows_worker', @@ -441,7 +429,6 @@ describe('orchestration federation', () => { authToken: 'worker-local-token', orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, orchestrationRequestId: 'remote_question_resume_request', - orchestrationCapability: capability, method: 'orchestration.ask', params: { from: 'term_windows_worker', resume: questionId, timeoutMs: 5_000 } }) @@ -457,14 +444,11 @@ describe('orchestration federation', () => { const task = createHomeTask() await homeDispatcher.dispatch(startRequest(task.id)) homeRuntime.stopOrchestrationFederationRelay() - const prompt = vi.mocked(workerRuntime.sendTerminalAgentPrompt).mock.calls[0]?.[1] ?? '' - const capability = prompt.match(/--dispatch-capability (dcap_[A-Za-z0-9_-]+)/)?.[1] await workerDispatcher.dispatch({ id: 'rpc_remote_status', authToken: 'worker-local-token', orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, orchestrationRequestId: 'remote_status_request', - orchestrationCapability: capability, method: 'orchestration.send', params: { from: 'term_windows_worker', @@ -575,15 +559,12 @@ describe('orchestration federation', () => { const task = createHomeTask() await homeDispatcher.dispatch(startRequest(task.id)) const dispatch = homeDb.getDispatchContext(task.id)! - const prompt = vi.mocked(workerRuntime.sendTerminalAgentPrompt).mock.calls[0]?.[1] ?? '' - const capability = prompt.match(/--dispatch-capability (dcap_[A-Za-z0-9_-]+)/)?.[1] homeRuntime.stopOrchestrationFederationRelay() await workerDispatcher.dispatch({ id: 'rpc_restart_status', authToken: 'worker-local-token', orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, orchestrationRequestId: 'restart_status_request', - orchestrationCapability: capability, method: 'orchestration.send', params: { from: 'term_windows_worker', diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts index 067f583e37b4..ca63c1fb46d7 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts @@ -239,7 +239,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ if (!paneKey || !processIncarnation) { throw new Error('stable_pane_required') } - const capability = db.prepareRemoteAttachmentAuthority({ + db.prepareRemoteAttachmentAuthority({ dispatchId: params.dispatchId, paneKey, processIncarnation, @@ -259,7 +259,6 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ taskSpec: params.taskSpec, coordinatorHandle: 'Run home (relayed by Orca)', workerHandle: terminalHandle, - dispatchCapability: capability, devMode: params.devMode, // Why the worker host's own setting: enforcement runs here, with this // host's code, against this host's cap. diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts index 9b49e7a07a11..25b8058e5dff 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts @@ -10,6 +10,8 @@ import { runCoordinatorKey } from '../../../../orchestration/orchestration-caller-identity' import { resolveOrchestrationParty } from '../../../../orchestration/orchestration-party' +import { assertLifecycleCallerIsNotAnotherParty } from './lifecycle-caller-fence' +import { assertWorkerCanReport } from '../../../../orchestration/worker-report-admission' export const ORCHESTRATION_ASK_METHODS = [ defineMethod({ @@ -17,7 +19,13 @@ export const ORCHESTRATION_ASK_METHODS = [ params: AskParams, handler: async ( params, - { runtime, signal, orchestrationCapability, recordMutationReceipt } + { + runtime, + signal, + recordMutationReceipt, + orchestrationCaller, + orchestrationCompatibilityEvidence + } ) => { // Why: group addresses have no unambiguous first-answer authority. if (params.to && isGroupAddress(params.to)) { @@ -32,19 +40,26 @@ export const ORCHESTRATION_ASK_METHODS = [ // Why: echoed on every return so a clamped caller reports the budget actually waited, not the one it asked for. const timeoutMs = clampOrchestrationAskTimeoutMs(params.timeoutMs) const paneKey = runtime.getTerminalPaneKey(from) ?? undefined + if (!orchestrationCaller) { + assertLifecycleCallerIsNotAnotherParty(runtime, { + from, + fromPaneKey: paneKey, + evidence: orchestrationCompatibilityEvidence + }) + } const remoteAttachment = paneKey ? db.findActiveRemoteAttachmentForPane(paneKey) : undefined - if (remoteAttachment) { + if (remoteAttachment && paneKey) { rejectFederatedExplicitTarget(params) return askRemoteRunHome({ params: { ...params, timeoutMs }, runtime, signal, - orchestrationCapability, recordMutationReceipt, from, - paneKey: paneKey as string, + paneKey, dispatchId: remoteAttachment.dispatch_id, - taskId: remoteAttachment.task_id + taskId: remoteAttachment.task_id, + workerState: remoteAttachment.state }) } const activeDispatch = db.getActiveDispatchForIdentity(from, paneKey) @@ -54,17 +69,18 @@ export const ORCHESTRATION_ASK_METHODS = [ 'ask requires an active supervised Dispatch.' ) } - if (activeDispatch.capability_hash) { - const authority = db.verifyDispatchCapability({ - dispatchId: activeDispatch.id, - capability: orchestrationCapability, - paneKey, - processIncarnation: runtime.getTerminalProcessIncarnation(from) ?? undefined - }) - if (!authority.valid) { - throw new OrchestrationError('dispatch_capability_invalid', authority.reason) - } - } + assertWorkerCanReport({ + dispatchId: activeDispatch.id, + from, + workerState: db.getWorkerDispatch(activeDispatch.id)?.state, + processCurrent: + !activeDispatch.process_incarnation || + db.isDispatchProcessCurrent({ + dispatchId: activeDispatch.id, + paneKey: paneKey ?? null, + processIncarnation: runtime.getTerminalProcessIncarnation(from) + }) + }) const options = params.options ?.split(',') diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/ask-remote.ts b/src/main/runtime/rpc/methods/orchestration/messaging/ask-remote.ts index da092fbaaafd..a37707d42095 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/ask-remote.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/ask-remote.ts @@ -1,35 +1,34 @@ import type { z } from 'zod' import type { OrcaRuntimeService } from '../../../../orca-runtime' +import type { WorkerDispatchState } from '../../../../orchestration/types' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { clampOrchestrationAskTimeoutMs } from '../../../../../../shared/orchestration-ask-timeout' import type { AskParams } from '../schemas' +import { assertWorkerCanReport } from '../../../../orchestration/worker-report-admission' export async function askRemoteRunHome(args: { params: z.infer runtime: OrcaRuntimeService signal?: AbortSignal - orchestrationCapability?: string recordMutationReceipt?: (receipt: unknown) => void from: string paneKey: string dispatchId: string taskId: string + workerState: WorkerDispatchState }): Promise { const db = args.runtime.getOrchestrationDb() const timeoutMs = clampOrchestrationAskTimeoutMs(args.params.timeoutMs) - if ( - !db.verifyRemoteAttachmentAuthority({ + assertWorkerCanReport({ + dispatchId: args.dispatchId, + from: args.from, + workerState: args.workerState, + processCurrent: db.isRemoteAttachmentProcessCurrent({ dispatchId: args.dispatchId, - capability: args.orchestrationCapability, paneKey: args.paneKey, processIncarnation: args.runtime.getTerminalProcessIncarnation(args.from) }) - ) { - throw new OrchestrationError( - 'dispatch_capability_invalid', - 'The remote Dispatch capability or exact worker process is invalid.' - ) - } + }) const options = args.params.options ?.split(',') diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/ask.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/ask.test.ts index 4a46861a1c1e..51798a3ee8ed 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/ask.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/ask.test.ts @@ -164,36 +164,34 @@ describe('orchestration RPC methods', () => { ) }) - it('requires the Dispatch capability before creating a question', async () => { + it('creates a question only from the exact process that owns the Dispatch', async () => { setup() - const { dispatch } = createAskingDispatch() - const capability = db.mintDispatchCapability({ - dispatchId: dispatch.id, - paneKey: 'tab_worker:leaf_worker', - processIncarnation: 'runtime_test:term_worker:1' - }) + const task = db.createTask({ spec: 'question work' }) + createRootDispatch( + db, + task.id, + 'term_worker', + 'tab_worker:leaf_worker', + undefined, + 'runtime_test:term_worker:1' + ) vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => handle === 'term_worker' ? 'tab_worker:leaf_worker' : coordinatorPaneKey ) + vi.spyOn(runtime, 'waitForMessage').mockResolvedValue('timed_out') + const incarnation = vi + .spyOn(runtime, 'getTerminalProcessIncarnation') + .mockReturnValue('runtime_test:term_worker:2') await expect( - call('orchestration.ask', { - from: 'term_worker', - question: 'unauthorized', - timeoutMs: 1 - }) - ).rejects.toMatchObject({ code: 'dispatch_capability_invalid' }) + call('orchestration.ask', { from: 'term_worker', question: 'stale', timeoutMs: 1 }) + ).rejects.toMatchObject({ code: 'worker_identity_changed' }) expect(db.getInbox(100).filter((message) => message.type === 'question')).toHaveLength(0) - ctx = { runtime, orchestrationCapability: capability } - vi.spyOn(runtime, 'waitForMessage').mockResolvedValue('timed_out') - const accepted = (await call('orchestration.ask', { - from: 'term_worker', - question: 'authorized', - timeoutMs: 1 - })) as { messageId: string; timedOut: boolean } - expect(accepted.messageId).toMatch(/^msg_/) - expect(accepted.timedOut).toBe(true) + incarnation.mockReturnValue('runtime_test:term_worker:1') + expect( + await call('orchestration.ask', { from: 'term_worker', question: 'current', timeoutMs: 1 }) + ).toMatchObject({ messageId: expect.stringMatching(/^msg_/), timedOut: true }) }) it('returns timedOut when no reply arrives in the window', async () => { diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/check-superseded-terminal.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-superseded-terminal.test.ts index 65cc3192b1d5..9287cc0c1dc4 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/check-superseded-terminal.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/check-superseded-terminal.test.ts @@ -55,7 +55,7 @@ describe('orchestration.check from a terminal whose Attempt was superseded', () ;({ db, ctx } = h.setup()) const task = db.createTask({ spec: 'work that moves terminals' }) const abandoned = startWorker(task.id, 'term_old', PANE_OLD) - db.abandonWorkerDispatch(abandoned) + db.abandonWorkerDispatch(abandoned, 'epoch_test') startWorker(task.id, 'term_new', PANE_NEW, abandoned) return abandoned } diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/check-worker-consumer-fencing.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-worker-consumer-fencing.test.ts index 350fb33de04d..54c64feed624 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/check-worker-consumer-fencing.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/check-worker-consumer-fencing.test.ts @@ -2,7 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { RpcContext } from '../../../core' import type { OrcaRuntimeService } from '../../../../orca-runtime' import type { OrchestrationDb } from '../../../../orchestration/db' -import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' +import { + createRootDispatch, + reattachDispatchConsumer +} from '../../../../orchestration/db/root-dispatch-test-fixture' import { createOrchestrationRpcHarness } from '../rpc-test-harness' const PANE_A = 'tab_a:cccccccc-cccc-4ccc-8ccc-cccccccccccc' @@ -30,7 +33,7 @@ describe('orchestration.check on a re-attached Dispatch', () => { ;({ db, runtime, ctx } = h.setup()) const task = db.createTask({ spec: 'worker that gets replaced' }) const dispatch = createRootDispatch(db, task.id, 'term_worker', PANE_A) - db.mintDispatchCapability({ + reattachDispatchConsumer(db, { dispatchId: dispatch.id, paneKey: PANE_A, processIncarnation: 'runtime:pty-a:1' @@ -53,7 +56,7 @@ describe('orchestration.check on a re-attached Dispatch', () => { } function reattach(dispatchId: string): void { - db.mintDispatchCapability({ + reattachDispatchConsumer(db, { dispatchId, paneKey: PANE_B, processIncarnation: 'runtime:pty-b:1' @@ -62,7 +65,7 @@ describe('orchestration.check on a re-attached Dispatch', () => { /** Same pane, new process: bumps the generation without moving the Dispatch off PANE_A. */ function remintOnSamePane(dispatchId: string): void { - db.mintDispatchCapability({ + reattachDispatchConsumer(db, { dispatchId, paneKey: PANE_A, processIncarnation: 'runtime:pty-a:2' diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/lifecycle-caller-fence.ts b/src/main/runtime/rpc/methods/orchestration/messaging/lifecycle-caller-fence.ts new file mode 100644 index 000000000000..cb8b58f2883f --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/messaging/lifecycle-caller-fence.ts @@ -0,0 +1,64 @@ +import type { OrchestrationCompatibilityEvidence } from '../../../../../../shared/orchestration-compatibility-evidence' +import type { OrcaRuntimeService } from '../../../../orca-runtime' +import type { OrchestrationDb } from '../../../../orchestration/db' +import { OrchestrationError } from '../../../../orchestration/orchestration-error' +import { isEquivalentPaneKey } from '../../../../orchestration/db/pane-key-match' + +/** + * Refuses a worker report or question sent from another orchestration party's terminal: a Run + * coordinator or the assignee of a different Dispatch. Env that names no live pane on this host + * (stale, foreign, scrubbed, absent), or a live pane that is no party, proves nothing and passes. + * Accepted limit: a tmux server keeps the env of the pane that started it, so a worker inside a tmux + * server started from a live coordinator or worker pane is refused. + */ +export function assertLifecycleCallerIsNotAnotherParty( + runtime: OrcaRuntimeService, + args: { + from: string + fromPaneKey: string | undefined + evidence: OrchestrationCompatibilityEvidence | undefined + } +): void { + const { evidence } = args + // Why pane key first: the env handle goes stale on remint, the pane key does not. + const callerHandle = evidence?.paneKey + ? runtime.getTerminalHandleForPaneKey(evidence.paneKey) + : evidence?.terminalHandle + const callerPaneKey = callerHandle ? runtime.getLiveTerminalPaneKey(callerHandle) : null + if ( + !callerHandle || + !callerPaneKey || + (args.fromPaneKey && isEquivalentPaneKey(callerPaneKey, args.fromPaneKey)) + ) { + return + } + const db = runtime.getOrchestrationDb() + const callerDispatchId = ownedDispatchId(db, callerHandle, callerPaneKey) + // Why: a stale --from handle can hide the worker's own terminal; only a different party is refused. + if (callerDispatchId && callerDispatchId === ownedDispatchId(db, args.from, args.fromPaneKey)) { + return + } + const party = db.getCurrentRunForPane(callerPaneKey) + ? 'a Run coordinator' + : callerDispatchId + ? 'a Dispatch worker' + : undefined + if (party) { + throw new OrchestrationError( + 'consumer_fenced', + `This terminal is ${party} and cannot report as ${args.from}; run the command from the worker's own terminal. No effects were applied.`, + { effectsApplied: false } + ) + } +} + +function ownedDispatchId( + db: OrchestrationDb, + handle: string, + paneKey: string | undefined +): string | undefined { + return ( + db.getActiveDispatchForIdentity(handle, paneKey)?.id ?? + (paneKey ? db.findActiveRemoteAttachmentForPane(paneKey)?.dispatch_id : undefined) + ) +} diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-safety.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-safety.test.ts index f5f2c57575f5..3f759cc3d15a 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-safety.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/run-bound-mailbox-safety.test.ts @@ -1,5 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' +import { + createRootDispatch, + reattachDispatchConsumer +} from '../../../../orchestration/db/root-dispatch-test-fixture' import { createOrchestrationRpcHarness } from '../rpc-test-harness' const LEAD = 'tab_lead:22222222-2222-4222-9222-222222222222' @@ -207,7 +210,7 @@ describe('Run-bound lead mailbox boundaries', () => { }) it('does not give a reused process the previous assignee mail', async () => { - state.db.mintDispatchCapability({ + reattachDispatchConsumer(state.db, { dispatchId: dispatch.id, paneKey: LEAD, processIncarnation: 'old:pty:1' diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-dispatch-authority.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-dispatch-authority.test.ts index 5a1b6ee8c277..9f57957a10a2 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-dispatch-authority.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-dispatch-authority.test.ts @@ -43,16 +43,6 @@ describe('orchestration.send Dispatch authority', () => { vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => handle === 'term_attacker' ? 'tab_attacker:leaf_attacker' : harness.coordinatorPaneKey ) - if (!legacyAuthority) { - ctx = { - runtime, - orchestrationCapability: db.mintDispatchCapability({ - dispatchId: attacker.id, - paneKey: 'tab_attacker:leaf_attacker', - processIncarnation: 'runtime_test:term_attacker:1' - }) - } - } const result = (await send({ from: 'term_attacker', diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts index 58dcceb55980..a98e016da751 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts @@ -2,7 +2,12 @@ import { defineMethod } from '../../../core' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { isGroupAddress } from '../../../../orchestration/groups' import { orchestrationSkillRecoveryData } from '../../../../../../shared/orchestration-rpc-contract' -import { SendParams, isWorkerReportOutcome, parseRemoteWorkerPayload } from '../schemas' +import { + SendParams, + isDispatchMutationMessageType, + isWorkerReportOutcome, + parseRemoteWorkerPayload +} from '../schemas' import { resolveMessageRun } from '../routing' import { assertDispatchMailboxDeliverable, @@ -22,6 +27,7 @@ import { sendPointToPointMessage } from './send-point-to-point' import { sendGroupMessage } from './send-group' import { sendFederatedControlMail } from './send-control-mail' import { orchestrationCallerIdentity } from '../runs/run-scope' +import { assertLifecycleCallerIsNotAnotherParty } from './lifecycle-caller-fence' export const ORCHESTRATION_SEND_METHODS = [ defineMethod({ @@ -31,7 +37,6 @@ export const ORCHESTRATION_SEND_METHODS = [ params, { runtime, - orchestrationCapability, legacyCoordinatorRunId, revalidateLegacyCoordinator, orchestrationCompatibilityCallerAuthority, @@ -39,6 +44,7 @@ export const ORCHESTRATION_SEND_METHODS = [ markWorkerDoneMutationEffectFree, replayedMutationReceipt, orchestrationCaller, + orchestrationCompatibilityEvidence, signal } ) => { @@ -69,6 +75,14 @@ export const ORCHESTRATION_SEND_METHODS = [ paneKey: attestedCaller?.paneKey ?? runtime.getTerminalPaneKey(from) }) const senderPaneKey = sender.paneKey ?? undefined + // Why: a session caller was already bound to its own identity at the dispatch entry. + if (isDispatchMutationMessageType(params.type) && !orchestrationCaller) { + assertLifecycleCallerIsNotAnotherParty(runtime, { + from, + fromPaneKey: senderPaneKey, + evidence: orchestrationCompatibilityEvidence + }) + } const remoteAttachment = senderPaneKey ? db.findActiveRemoteAttachmentForPane(senderPaneKey) : undefined @@ -84,7 +98,6 @@ export const ORCHESTRATION_SEND_METHODS = [ attestedCaller?.processIncarnation ?? runtime.getTerminalProcessIncarnation(from) ?? undefined, - orchestrationCapability, signal }) } @@ -205,7 +218,6 @@ export const ORCHESTRATION_SEND_METHODS = [ messageRunId, senderPaneKey, legacyCoordinatorRunId, - orchestrationCapability, resolveProcessIncarnation: () => attestedCaller?.processIncarnation ?? runtime.getTerminalProcessIncarnation(from) ?? diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-point-to-point.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-point-to-point.ts index 807e709003a2..9a6852f9ab70 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-point-to-point.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-point-to-point.ts @@ -8,6 +8,10 @@ import { legacyWorkerDeliveryContract } from '../routing' import { exposeMessage } from './mailbox-message-receipt' import { recordReceiptForPostCommitNudge } from './mutation-replay-nudge' import type { SendRecipientWarning } from './recipient-routing' +import { + workerReportRefusal, + type WorkerReportRefusal +} from '../../../../orchestration/worker-report-admission' import type { z } from 'zod' type SendParamsInput = z.infer @@ -23,7 +27,6 @@ export function sendPointToPointMessage(args: { messageRunId: string | undefined senderPaneKey: string | undefined legacyCoordinatorRunId: string | undefined - orchestrationCapability: string | undefined resolveProcessIncarnation: () => string | undefined revalidateLegacyCoordinator: (() => string) | undefined recordMutationReceipt: ((receipt: unknown) => void) | undefined @@ -40,7 +43,6 @@ export function sendPointToPointMessage(args: { messageRunId, senderPaneKey, legacyCoordinatorRunId, - orchestrationCapability, resolveProcessIncarnation, revalidateLegacyCoordinator, recordMutationReceipt, @@ -76,28 +78,25 @@ export function sendPointToPointMessage(args: { }) if (isDispatchMutationMessageType(msg.type)) { const taskId = parseMessageTaskId(params.payload) - const capabilityBacked = Boolean(dispatch?.capability_hash) const coordinatorMutation = msg.type === 'escalation' || msg.type === 'decision_gate' - const authority = resolveLifecycleAuthority({ + const refusal = lifecycleRefusal({ db, dispatch, from, paneKey: senderPaneKey, processIncarnation, - capability: orchestrationCapability, taskId, - capabilityBacked, coordinatorMutation }) - if (!authority.valid) { + if (refusal) { const rejection = - db.convertLifecycleMessageToRejection(msg.id, authority.code, authority.reason) ?? msg + db.convertLifecycleMessageToRejection(msg.id, refusal.code, refusal.reason) ?? msg const receipt = withSendWarnings({ message: exposeMessage(rejection), lifecycle: { action: 'rejected', - code: authority.code, - reason: authority.reason + code: refusal.code, + reason: refusal.reason } }) return recordReceiptForPostCommitNudge(recordMutationReceipt, receipt, () => @@ -152,81 +151,55 @@ export function sendPointToPointMessage(args: { return committed.receipt } -type LifecycleAuthority = { - valid: boolean - code: 'sender_not_assignee' | 'task_dispatch_mismatch' | 'dispatch_capability_invalid' +type LifecycleRefusal = { + code: 'sender_not_assignee' | 'task_dispatch_mismatch' | WorkerReportRefusal['code'] reason: string } -function resolveLifecycleAuthority(args: { +function lifecycleRefusal(args: { db: OrchestrationDb dispatch: ReturnType from: string paneKey: string | undefined processIncarnation: string | undefined - capability: string | undefined taskId: string | undefined - capabilityBacked: boolean coordinatorMutation: boolean -}): LifecycleAuthority { - const { - db, - dispatch, - from, - paneKey, - processIncarnation, - capability, - taskId, - capabilityBacked, - coordinatorMutation - } = args +}): LifecycleRefusal | null { + const { db, dispatch, from, paneKey, processIncarnation, taskId, coordinatorMutation } = args if (!dispatch) { - return { - valid: !coordinatorMutation, - code: 'sender_not_assignee', - reason: 'No active Dispatch belongs to this message sender.' - } + return coordinatorMutation + ? { + code: 'sender_not_assignee', + reason: 'No active Dispatch belongs to this message sender.' + } + : null } if (coordinatorMutation && taskId && taskId !== dispatch.task_id) { return { - valid: false, code: 'task_dispatch_mismatch', reason: `Task ${taskId} does not belong to Dispatch ${dispatch.id}.` } } - if (capabilityBacked) { - const authority = db.verifyDispatchCapability({ - dispatchId: dispatch.id, - capability, - paneKey, - processIncarnation - }) - return { - valid: authority.valid, - code: 'dispatch_capability_invalid', - reason: authority.valid ? '' : authority.reason - } - } - if (dispatch.process_incarnation) { - return { - valid: db.isDispatchProcessCurrent({ + const refusal = workerReportRefusal({ + dispatchId: dispatch.id, + from, + workerState: db.getWorkerDispatch(dispatch.id)?.state, + processCurrent: + !dispatch.process_incarnation || + db.isDispatchProcessCurrent({ dispatchId: dispatch.id, paneKey: paneKey ?? null, processIncarnation: processIncarnation ?? null - }), - code: 'sender_not_assignee', - reason: `Dispatch ${dispatch.id} process incarnation is no longer current for its pane.` - } - } - return { - valid: - !coordinatorMutation || - db.isDispatchMessageSender({ - dispatchId: dispatch.id, - handle: from, - paneKey - }), - code: 'sender_not_assignee', - reason: `Terminal ${from} does not own Dispatch ${dispatch.id}.` + }) + }) + if (refusal || dispatch.process_incarnation) { + return refusal } + return !coordinatorMutation || + db.isDispatchMessageSender({ dispatchId: dispatch.id, handle: from, paneKey }) + ? null + : { + code: 'sender_not_assignee', + reason: `Terminal ${from} does not own Dispatch ${dispatch.id}.` + } } diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-remote.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-remote.ts index ffd327a8f151..1f0b4369e941 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-remote.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-remote.ts @@ -1,4 +1,5 @@ import type { MessageType, OrchestrationDb } from '../../../../orchestration/db' +import type { WorkerDispatchState } from '../../../../orchestration/types' import type { OrcaRuntimeService } from '../../../../orca-runtime' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { waitForFederatedLifecycleSettlement } from '../../../../orchestration/federation-lifecycle-settlement' @@ -8,11 +9,13 @@ import type { z } from 'zod' import { parseRemoteWorkerPayload } from '../schemas' import type { SendParams } from '../schemas' import { rejectFederatedExplicitTarget } from '../routing' +import { assertWorkerCanReport } from '../../../../orchestration/worker-report-admission' type SendParamsInput = z.infer type RemoteAttachment = { dispatch_id: string + state: WorkerDispatchState protocol_version: number } @@ -24,24 +27,20 @@ export async function sendRemoteMessage(args: { senderPaneKey: string remoteAttachment: RemoteAttachment processIncarnation?: string | null - orchestrationCapability?: string signal?: AbortSignal }): Promise { const { params, runtime, db, from, senderPaneKey, remoteAttachment } = args rejectFederatedExplicitTarget(params) - if ( - !db.verifyRemoteAttachmentAuthority({ + assertWorkerCanReport({ + dispatchId: remoteAttachment.dispatch_id, + from, + workerState: remoteAttachment.state, + processCurrent: db.isRemoteAttachmentProcessCurrent({ dispatchId: remoteAttachment.dispatch_id, - capability: args.orchestrationCapability, paneKey: senderPaneKey, processIncarnation: args.processIncarnation ?? null }) - ) { - throw new OrchestrationError( - 'dispatch_capability_invalid', - 'The remote Dispatch capability or exact worker process is invalid.' - ) - } + }) const type = (params.type ?? 'status') as MessageType const payload = parseRemoteWorkerPayload(params.payload) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts index bb57df1a6f59..6d4e1e1c19b3 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts @@ -254,8 +254,8 @@ describe('orchestration RPC methods', () => { expect(rejected.lifecycle).toEqual({ action: 'rejected', - code: 'sender_not_assignee', - reason: `Dispatch ${dispatch.id} process incarnation is no longer current for its pane.` + code: 'worker_identity_changed', + reason: `term_worker is not the exact process that owns Dispatch ${dispatch.id}.` }) expect(db.getTask(task.id)?.status).toBe('dispatched') @@ -269,6 +269,36 @@ describe('orchestration RPC methods', () => { expect(accepted.lifecycle.action).toBe('completed') }) + it('rejects a current process whose terminal now resolves to another pane', async () => { + setup() + const task = db.createTask({ spec: 'pane-bound manual work' }) + const dispatch = createRootDispatch( + db, + task.id, + 'term_worker', + 'tab_worker:leaf_worker', + undefined, + 'runtime_test:term_worker:1' + ) + vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => + handle === 'term_worker' ? 'tab_foreign:leaf_foreign' : coordinatorPaneKey + ) + + expect( + await call('orchestration.send', { + from: 'term_worker', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ + taskId: task.id, + dispatchId: dispatch.id, + outcome: 'succeeded' + }) + }) + ).toMatchObject({ lifecycle: { action: 'rejected', code: 'worker_identity_changed' } }) + expect(db.getTask(task.id)?.status).toBe('dispatched') + }) + it.each(['escalation', 'decision_gate'] as const)( 'fences a replacement process from a %s mutation', async (type) => { @@ -304,7 +334,7 @@ describe('orchestration RPC methods', () => { expect(rejected.lifecycle).toMatchObject({ action: 'rejected', - code: 'sender_not_assignee' + code: 'worker_identity_changed' }) expect(db.getTask(task.id)?.status).toBe('dispatched') expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched') @@ -389,88 +419,6 @@ describe('orchestration RPC methods', () => { expect(runtime.notifyMessageArrived).toHaveBeenCalledWith(`run:${activeRunId}`, 'worker_done') }) - it('requires the minted capability, exact pane, and process incarnation', async () => { - setup() - const task = db.createTask({ spec: 'capability work' }) - const dispatch = createRootDispatch(db, task.id, 'term_worker', 'tab_worker:leaf_worker') - const capability = db.mintDispatchCapability({ - dispatchId: dispatch.id, - paneKey: 'tab_worker:leaf_worker', - processIncarnation: 'runtime_test:term_worker:1' - }) - vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => - handle === 'term_worker' ? 'tab_worker:leaf_worker' : coordinatorPaneKey - ) - const payload = JSON.stringify({ - taskId: task.id, - dispatchId: dispatch.id, - outcome: 'succeeded' - }) - - const rejected = (await call('orchestration.send', { - from: 'term_worker', - subject: 'Done', - type: 'worker_done', - payload - })) as { lifecycle: { code: string }; message: { subject: string } } - expect(rejected).toMatchObject({ - lifecycle: { code: 'dispatch_capability_invalid' }, - message: { subject: 'Rejected worker_done: Done' } - }) - expect(db.getTask(task.id)?.status).toBe('dispatched') - - ctx = { runtime, orchestrationCapability: 'dcap_wrong' } - const wrongToken = (await call('orchestration.send', { - from: 'term_worker', - subject: 'Done', - type: 'worker_done', - payload - })) as { lifecycle: { code: string } } - expect(wrongToken.lifecycle.code).toBe('dispatch_capability_invalid') - - ctx = { runtime, orchestrationCapability: capability } - vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => - handle === 'term_worker' ? 'tab_foreign:leaf_foreign' : coordinatorPaneKey - ) - const wrongPane = (await call('orchestration.send', { - from: 'term_worker', - subject: 'Done', - type: 'worker_done', - payload - })) as { lifecycle: { code: string } } - expect(wrongPane.lifecycle.code).toBe('dispatch_capability_invalid') - - vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => - handle === 'term_worker' ? 'tab_worker:leaf_worker' : coordinatorPaneKey - ) - vi.mocked(runtime.getTerminalProcessIncarnation).mockReturnValue('runtime_test:term_worker:2') - const wrongProcess = (await call('orchestration.send', { - from: 'term_worker', - subject: 'Done', - type: 'worker_done', - payload - })) as { lifecycle: { code: string } } - expect(wrongProcess.lifecycle.code).toBe('dispatch_capability_invalid') - - vi.mocked(runtime.getTerminalProcessIncarnation).mockReturnValue('runtime_test:term_worker:1') - await call('orchestration.send', { - from: 'term_worker', - subject: 'Done', - type: 'worker_done', - payload - }) - expect(db.getTask(task.id)?.status).toBe('completed') - expect(db.getDispatchContextById(dispatch.id)?.capability_revoked_at).toBeTruthy() - - const revoked = (await call('orchestration.send', { - from: 'term_worker', - subject: 'Done again', - type: 'worker_done', - payload - })) as { lifecycle: { code: string } } - expect(revoked.lifecycle.code).toBe('dispatch_capability_invalid') - }) - it('does not wake waiters for a heartbeat suppressed at send time', async () => { setup() const task = db.createTask({ spec: 'work' }) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/worker-report-authority.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/worker-report-authority.test.ts new file mode 100644 index 000000000000..6c311baf84d1 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/messaging/worker-report-authority.test.ts @@ -0,0 +1,253 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcContext } from '../../../core' +import type { OrchestrationDb } from '../../../../orchestration/db' +import type { OrcaRuntimeService } from '../../../../orca-runtime' +import type { OrchestrationCompatibilityEvidence } from '../../../../../../shared/orchestration-compatibility-evidence' +import { createOrchestrationRpcHarness } from '../rpc-test-harness' + +const WORKER_PANE = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const OTHER_WORKER_PANE = 'tab_other:cccccccc-cccc-4ccc-8ccc-cccccccccccc' +const NON_PARTY_PANE = 'tab_teammate:dddddddd-dddd-4ddd-8ddd-dddddddddddd' +const WORKER_PROCESS = 'runtime_test:term_worker:1' + +describe('worker report authority without a Dispatch capability', () => { + const harness = createOrchestrationRpcHarness() + let db: OrchestrationDb + let runtime: OrcaRuntimeService + let ctx: RpcContext + let panes: Record + + afterEach(() => harness.cleanup()) + + function setup(): void { + ;({ db, runtime } = harness.setup()) + ctx = { runtime } + panes = { + term_coord: harness.coordinatorPaneKey, + term_worker: WORKER_PANE, + term_other: OTHER_WORKER_PANE, + term_teammate: NON_PARTY_PANE + } + vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => panes[handle] ?? null) + vi.spyOn(runtime, 'getTerminalHandleForPaneKey').mockImplementation( + (paneKey) => Object.keys(panes).find((handle) => panes[handle] === paneKey) ?? null + ) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + vi.spyOn(runtime, 'waitForMessage').mockResolvedValue('timed_out') + } + + function startWorker(name: string, paneKey: string): { taskId: string; dispatchId: string } { + const task = db.createTask({ spec: `${name} work` }) + const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) + db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: `term_${name}`, + paneKey, + processIncarnation: `runtime_test:term_${name}:1`, + worktreeId: `repo::${name}`, + effects: [], + setupState: 'not_applicable', + terminalOwnership: 'created' + }) + db.markWorkerDispatchReady(started.dispatch.id) + return { taskId: task.id, dispatchId: started.dispatch.id } + } + + function workerDone( + worker: { taskId: string; dispatchId: string }, + evidence?: OrchestrationCompatibilityEvidence, + outcome = 'succeeded' + ) { + return harness.call( + 'orchestration.send', + { + from: 'term_worker', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ ...worker, outcome }) + }, + { ...ctx, orchestrationCompatibilityEvidence: evidence } + ) + } + + function ask(evidence?: OrchestrationCompatibilityEvidence) { + return harness.call( + 'orchestration.ask', + { from: 'term_worker', question: 'Proceed?', timeoutMs: 1 }, + { ...ctx, orchestrationCompatibilityEvidence: evidence } + ) + } + + describe('caller fence', () => { + it.each([ + ['the Run coordinator', harness.coordinatorPaneKey], + ['another Dispatch worker', OTHER_WORKER_PANE] + ])('refuses a report sent from %s terminal and records nothing', async (_party, paneKey) => { + setup() + startWorker('other', OTHER_WORKER_PANE) + const worker = startWorker('worker', WORKER_PANE) + + await expect(workerDone(worker, { paneKey })).rejects.toMatchObject({ + code: 'consumer_fenced', + data: { effectsApplied: false } + }) + await expect(ask({ paneKey })).rejects.toMatchObject({ code: 'consumer_fenced' }) + expect( + db.db + .prepare("SELECT COUNT(*) AS count FROM messages WHERE from_handle = 'term_worker'") + .get() + ).toEqual({ count: 0 }) + expect(db.getTask(worker.taskId)?.status).toBe('dispatched') + }) + + it.each<[string, OrchestrationCompatibilityEvidence | undefined]>([ + ['no identity env (old CLI, scrubbed env)', undefined], + ['its own pane', { paneKey: WORKER_PANE, terminalHandle: 'term_stale_after_remint' }], + ["another Orca's pane", { paneKey: 'tab_elsewhere:eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee' }], + ['a stale handle', { terminalHandle: 'term_gone' }], + ['a live pane that is no orchestration party', { paneKey: NON_PARTY_PANE }] + ])('accepts a report whose env names %s', async (_case, evidence) => { + setup() + const worker = startWorker('worker', WORKER_PANE) + + expect(await workerDone(worker, evidence)).toMatchObject({ + lifecycle: { action: 'completed' } + }) + expect(db.getTask(worker.taskId)?.status).toBe('completed') + }) + + it('passes the fence for a stale --from handle and leaves refusal to the process check', async () => { + setup() + const worker = startWorker('worker', WORKER_PANE) + panes = { ...panes, term_worker_reminted: WORKER_PANE } + delete panes.term_worker + + expect(await workerDone(worker, { paneKey: WORKER_PANE })).toMatchObject({ + lifecycle: { action: 'rejected', code: 'worker_identity_changed' } + }) + }) + }) + + describe('worker states', () => { + it('refuses reports and questions while a stop is in flight', async () => { + setup() + const worker = startWorker('worker', WORKER_PANE) + db.beginWorkerStop(worker.dispatchId, 'epoch_home') + + expect(await workerDone(worker)).toMatchObject({ + lifecycle: { action: 'rejected', code: 'dispatch_inactive' } + }) + await expect(ask()).rejects.toMatchObject({ code: 'dispatch_inactive' }) + expect(db.getWorkerDispatch(worker.dispatchId)?.state).toBe('stopping') + }) + + it.each([ + ['succeeded', 'completed', 'succeeded'], + ['failed', 'failed', 'failed'] + ])( + 'settles a %s report after a stop whose outcome is unknown', + async (outcome, taskStatus, workerState) => { + setup() + const worker = startWorker('worker', WORKER_PANE) + db.beginWorkerStop(worker.dispatchId, 'epoch_home') + db.markWorkerStopUnknown(worker.dispatchId, 'tab not owned by Orca') + + expect(await workerDone(worker, undefined, outcome)).toMatchObject({ + lifecycle: { action: taskStatus } + }) + expect(db.getTask(worker.taskId)?.status).toBe(taskStatus) + expect(db.getWorkerDispatch(worker.dispatchId)).toMatchObject({ + state: workerState, + ...(outcome === 'succeeded' ? { last_error: null } : {}) + }) + } + ) + + it('refuses a stale process in the worker pane after an unknown stop', async () => { + setup() + const worker = startWorker('worker', WORKER_PANE) + db.beginWorkerStop(worker.dispatchId, 'epoch_home') + db.markWorkerStopUnknown(worker.dispatchId, 'tab not owned by Orca') + vi.mocked(runtime.getTerminalProcessIncarnation).mockReturnValue('runtime_test:term_worker:2') + + expect(await workerDone(worker)).toMatchObject({ + lifecycle: { code: 'worker_identity_changed' } + }) + await expect(ask()).rejects.toMatchObject({ code: 'worker_identity_changed' }) + }) + }) + + describe('remote worker states', () => { + function startRemoteWorker(): string { + const dispatchId = 'ctx_remote_worker' + db.createRemoteDispatchAttachment({ + runId: 'run-home', + dispatchId, + taskId: 'task_remote_worker', + homePeerFingerprint: 'run-home-device', + protocolVersion: 1, + runtimeEpoch: 'epoch_worker_host', + mutationReceipt: { + callerFingerprint: 'run-home-device', + requestId: 'remote_attach', + method: 'orchestration.federationAttachStart', + payloadHash: 'remote_attach_payload' + } + }) + db.prepareRemoteAttachmentAuthority({ + dispatchId, + paneKey: WORKER_PANE, + processIncarnation: WORKER_PROCESS, + worktreeId: 'repo::remote', + terminalHandle: 'term_worker', + setupState: 'completed', + effects: [] + }) + return dispatchId + } + + it.each([ + [ + 'stop_unknown', + (dispatchId: string) => { + db.markRemoteAttachmentReady(dispatchId) + db.beginRemoteAttachmentStop(dispatchId) + db.markRemoteAttachmentStopUnknown(dispatchId, 'tab not owned by Orca') + } + ], + [ + 'start_unknown', + (dispatchId: string) => + db.failRemoteAttachment(dispatchId, 'agent_readiness', 'connection lost', true) + ] + ])('settles a remote report from %s', async (state, reachState) => { + setup() + const dispatchId = startRemoteWorker() + reachState(dispatchId) + expect(db.getRemoteDispatchAttachment(dispatchId)?.state).toBe(state) + + expect(await workerDone({ taskId: 'task_remote_worker', dispatchId })).toMatchObject({ + lifecycle: { action: 'completed' } + }) + expect(db.getRemoteDispatchAttachment(dispatchId)?.state).toBe('succeeded') + }) + + it('refuses a remote report while the stop is in flight', async () => { + setup() + const dispatchId = startRemoteWorker() + db.markRemoteAttachmentReady(dispatchId) + db.beginRemoteAttachmentStop(dispatchId) + + await expect(workerDone({ taskId: 'task_remote_worker', dispatchId })).rejects.toMatchObject({ + code: 'dispatch_inactive' + }) + await expect(ask()).rejects.toMatchObject({ code: 'dispatch_inactive' }) + expect(db.listPendingFederationRelay(dispatchId, 'to_home')).toEqual([]) + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts index 843df33357a6..0a8b59265cd2 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts @@ -136,13 +136,6 @@ export const ORCHESTRATION_DISPATCH_METHODS = [ creator: resolveDispatchCreator(runtime, params.from, orchestrationCaller), maxDepth: runtime.getNestedWorkerMaxDepth() }) - const dispatchCapability = params.inject - ? db.mintDispatchCapability({ - dispatchId: ctx.id, - paneKey: assigneePaneKey as string, - processIncarnation: processIncarnation as string - }) - : undefined // Why: built after ctx so dispatchId is the real ctx.id, letting heartbeats attribute liveness to a specific dispatch context, not just a task. const preamble = buildDispatchPreamble({ @@ -152,7 +145,6 @@ export const ORCHESTRATION_DISPATCH_METHODS = [ taskSpec: task.spec, coordinatorHandle: params.from ?? 'coordinator', workerHandle: to, - dispatchCapability, devMode: params.devMode, cliCommand: runtime.getTerminalOrchestrationCliCommand(to) }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts index d265858b0243..2c95af1aa072 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts @@ -161,7 +161,7 @@ describe('orchestration RPC methods', () => { }) expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledWith( 'term_worker', - expect.stringContaining('--dispatch-capability dcap_'), + expect.any(String), expect.objectContaining(dispatchPreambleSendOptions(expect.any(String))) ) }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts index bc2b9601cebc..0d80079c13d8 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts @@ -27,7 +27,6 @@ export async function deliverWorkerDispatchPreamble(args: { taskId: string taskSpec: string coordinatorHandle: string - dispatchCapability: string devMode: boolean | undefined requestId: string }): Promise<{ @@ -45,7 +44,6 @@ export async function deliverWorkerDispatchPreamble(args: { taskSpec: args.taskSpec, coordinatorHandle: args.coordinatorHandle, workerHandle: terminalHandle, - dispatchCapability: args.dispatchCapability, devMode: args.devMode, cliCommand: runtime.getTerminalOrchestrationCliCommand(terminalHandle) }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index d6b265c41a97..7e10274435b5 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -215,7 +215,7 @@ export async function startLocalWorker(args: { } } const terminalAuthority = requireWorkerAuthority(runtime, terminalHandle) - const capability = db.prepareStartingWorkerAuthority({ + db.prepareStartingWorkerAuthority({ dispatchId: started.dispatch.id, handle: terminalHandle, ...terminalAuthority, @@ -235,7 +235,6 @@ export async function startLocalWorker(args: { structuredSession, terminalHandle, coordinatorHandle: params.from, - dispatchCapability: capability, devMode: params.devMode, requestId: orchestrationMutation?.requestId ?? started.dispatch.id, agent: agent ?? null, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-observation.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-observation.test.ts index 25da0b311a38..06b16eaff1ac 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-observation.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-observation.test.ts @@ -74,8 +74,7 @@ describe('manual Dispatch observation', () => { expect(db.getDispatchContextById(result.dispatch.id)).toMatchObject({ assignee_handle: 'term_worker', assignee_pane_key: workerPaneKey, - process_incarnation: 'runtime_test:term_worker:1', - capability_hash: expect.any(String) + process_incarnation: 'runtime_test:term_worker:1' }) const workerShowMethod = eraseRpcMethods(ORCHESTRATION_METHODS).find( @@ -89,7 +88,7 @@ describe('manual Dispatch observation', () => { runtime }) ).resolves.toMatchObject({ - worker: { state: 'unsupervised', stage: 'injected' }, + worker: { state: 'unsupervised', stage: 'context_only' }, observation: { status: 'live', exactWorker: true } }) }) @@ -126,11 +125,6 @@ describe('manual Dispatch observation', () => { 'launch-hash', 'runtime_test:term_worker:1' ) - db.mintDispatchCapability({ - dispatchId: dispatch.id, - paneKey: 'tab_worker:leaf_worker', - processIncarnation: 'runtime_test:term_worker:1' - }) const context = { runtime } const call = async (name: string, params: Record) => { const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( @@ -180,7 +174,7 @@ describe('manual Dispatch observation', () => { dispatch: dispatch.id })) as { projection: { liveness: { verdict: string } } | null } expect(workerShow).toMatchObject({ - worker: { state: 'unsupervised', stage: 'injected', agentTerminalHandle: 'term_worker' }, + worker: { state: 'unsupervised', stage: 'context_only', agentTerminalHandle: 'term_worker' }, observation: { status: 'live', exactWorker: true } }) // Why: worker-show published only PTY liveness, so it read `live` for a dispatch that diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-abandon-caller.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-abandon-caller.test.ts new file mode 100644 index 000000000000..ab7588bf7d3b --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-abandon-caller.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { OrcaRuntimeService } from '../../../../orca-runtime' +import { OrchestrationDb } from '../../../../orchestration/db' +import type { OrchestrationSessionCaller } from '../../../../orchestration/orchestration-caller-identity' +import { ORCHESTRATION_METHODS } from '../../orchestration' +import { eraseRpcMethods, type RpcContext } from '../../../core' +import { parseOrcaSessionAddress } from '../../../../../../shared/orca-session-address' + +describe('orchestration.workerAbandon', () => { + let db: OrchestrationDb + let runtime: OrcaRuntimeService + + beforeEach(() => { + db = new OrchestrationDb(':memory:') + runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + }) + afterEach(() => db.close()) + + function readyWorker(): string { + const task = db.createTask({ runId: 'run_legacy_local', spec: 'abandon caller' }) + const { dispatch } = db.createStartingWorkerDispatch({ + taskId: task.id, + startOptions: {}, + creator: { kind: 'system' }, + maxDepth: 9 + }) + db.prepareStartingWorkerAuthority({ + dispatchId: dispatch.id, + handle: 'term_worker', + paneKey: 'tab_worker:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + processIncarnation: 'inc_worker', + worktreeId: 'wt', + effects: [], + setupState: 'not_configured' + }) + db.markWorkerDispatchReady(dispatch.id) + return dispatch.id + } + + async function abandon(dispatchId: string, ctx: Partial) { + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find( + (m) => m.name === 'orchestration.workerAbandon' + )! + return method.handler(method.params!.parse({ dispatch: dispatchId }), { runtime, ...ctx }) + } + + it('never records an unverified terminal handle as the one who abandoned the worker', async () => { + const dispatchId = readyWorker() + + await expect( + abandon(dispatchId, { orchestrationCompatibilityEvidence: { terminalHandle: 'term_coord' } }) + ).resolves.toMatchObject({ state: 'abandoned', alreadySettled: false }) + expect(db.getWorkerDispatch(dispatchId)?.last_error).toBe( + 'Abandoned by an unidentified caller.' + ) + }) + + it('records the resolved Orca session as the one who abandoned the worker', async () => { + const dispatchId = readyWorker() + const orcaSessionId = parseOrcaSessionAddress('session:chat_1')! + const session: OrchestrationSessionCaller = { + address: 'session:chat_1', + terminalHandle: null, + paneKey: null, + orcaSessionId, + sessionId: orcaSessionId, + workspaceId: 'wt' + } + + await abandon(dispatchId, { + orchestrationCaller: session, + orchestrationCompatibilityEvidence: { terminalHandle: 'term_coord' } + }) + expect(db.getWorkerDispatch(dispatchId)?.last_error).toBe('Abandoned by session:chat_1.') + }) + + it('reports an already-settled worker as stale and changes nothing', async () => { + const dispatchId = readyWorker() + db.failDispatch(dispatchId, 'tab closed', { workerProcessExited: true }) + const before = db.getWorkerDispatch(dispatchId) + + await expect(abandon(dispatchId, {})).resolves.toMatchObject({ + state: 'failed', + alreadySettled: true, + stale: true + }) + expect(db.getWorkerDispatch(dispatchId)).toEqual(before) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts index a9211532626a..9fe3b3979252 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts @@ -199,8 +199,13 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS = [ defineMethod({ name: 'orchestration.workerAbandon', params: WorkerDispatchParams, - handler: (params, { runtime }) => { - const abandoned = runtime.getOrchestrationDb().abandonWorkerDispatch(params.dispatch) + handler: (params, { runtime, orchestrationCaller }) => { + const abandoned = runtime.getOrchestrationDb().abandonWorkerDispatch( + params.dispatch, + runtime.getRuntimeId(), + // Why: only a session caller is verified; terminal env could name anyone. + orchestrationCaller?.address + ) if (abandoned.disposition === 'context_only') { if (!abandoned.alreadySettled) { // Abandon settles the Dispatch, so it owes the same binding release stop and release do: @@ -227,12 +232,13 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS = [ dispatchId: params.dispatch, state: worker.state, alreadySettled: abandoned.disposition !== 'abandoned', - stale: abandoned.disposition === 'stale', + // Kept for --json readers: this attempt was no longer current, as main reported it. + stale: abandoned.superseded || abandoned.disposition === 'already_settled', processAction: 'none', warning: - abandoned.disposition === 'stale' - ? 'The Dispatch is no longer current; no state or process changed.' - : 'Possibly-live resources were retained; no process was stopped or deleted.', + abandoned.disposition === 'abandoned' + ? 'Possibly-live resources were retained; no process was stopped or deleted.' + : `The worker was already ${worker.state}; its terminal was left open and no process changed.`, residualResources: JSON.parse(worker.residual_resources) as unknown[] } } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-interactive-wait.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-interactive-wait.test.ts index 956cecc5bc46..7b1e56f8fd22 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-interactive-wait.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-interactive-wait.test.ts @@ -109,11 +109,6 @@ describe('worker-show interactive wait (STA-3714, STA-4513)', () => { 'launch-hash', // A dispatch recorded against a process that has since been replaced. opts?.breakIdentity === true ? `${incarnation}:replaced` : incarnation ) - db.mintDispatchCapability({ - dispatchId: dispatch.id, - paneKey, - processIncarnation: opts?.breakIdentity === true ? `${incarnation}:replaced` : incarnation - }) const method = workerShowMethod() return method.handler(method.params?.parse({ dispatch: dispatch.id }), { runtime }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts index f1202c6e7cfe..7bef1dc934cc 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts @@ -177,7 +177,8 @@ function exposeContextOnlyWorker(dispatch: DispatchContextRow) { dispatchId: dispatch.id, runtimeEpoch: null, state: 'unsupervised' as const, - stage: dispatch.capability_hash ? 'injected' : 'context_only', + // Why: with no worker row Orca supervises only the context; it keeps no record of an --inject paste. + stage: 'context_only', worktreeId: null, agentTerminalHandle: dispatch.assignee_handle, setupState: 'not_applicable', diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.test.ts index d7f6e9502bff..9a6a399cfb16 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.test.ts @@ -53,7 +53,7 @@ describe('workerRelease on a retained resource whose process exited', () => { it('records the archive as unavailable rather than retaining the pane forever', async () => { const { dispatchId } = await harness.startWorker() // Abandoned workers never reach `requested`, the only state that writes an archive. - expect(harness.db.abandonWorkerDispatch(dispatchId).disposition).toBe('abandoned') + expect(harness.db.abandonWorkerDispatch(dispatchId, 'epoch_test').disposition).toBe('abandoned') expect(harness.db.getWorkerTerminalArchive(dispatchId)).toBeFalsy() harness.inspectProcessLiveness.mockResolvedValue('exited') @@ -83,7 +83,7 @@ describe('workerRelease on a retained resource whose process exited', () => { it('still refuses when an archive names a different resource', async () => { const { dispatchId } = await harness.startWorker() const resource = harness.db.getWorkerTerminalResourceByOwner(dispatchId)! - expect(harness.db.abandonWorkerDispatch(dispatchId).disposition).toBe('abandoned') + expect(harness.db.abandonWorkerDispatch(dispatchId, 'epoch_test').disposition).toBe('abandoned') harness.db.storeWorkerTerminalArchive({ dispatchId, resourceId: `${resource.id}-other`, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts index e6466ed48c47..f6f84c4ee4ac 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts @@ -192,7 +192,7 @@ describe('orchestration worker release', () => { h.db.beginWorkerStop(dispatchId, h.runtime.getRuntimeId()) h.db.settleWorkerStop(dispatchId) } else { - h.db.abandonWorkerDispatch(dispatchId) + h.db.abandonWorkerDispatch(dispatchId, 'epoch_test') } h.inspectProcessLiveness.mockResolvedValue('exited') @@ -221,7 +221,7 @@ describe('orchestration worker release', () => { h.db.beginWorkerStop(dispatchId, h.runtime.getRuntimeId()) h.db.settleWorkerStop(dispatchId) } else { - h.db.abandonWorkerDispatch(dispatchId) + h.db.abandonWorkerDispatch(dispatchId, 'epoch_test') } h.inspectProcessLiveness.mockResolvedValue('unverifiable') diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-budget.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-budget.test.ts index d258076a6a3d..983dc9c4a957 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-budget.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-budget.test.ts @@ -15,7 +15,6 @@ describe('worker-start prompt budget', () => { const prompt = buildDispatchPreamble({ taskId: 'task_test', dispatchId: 'ctx_test', - dispatchCapability: `dcap_${'A'.repeat(43)}`, taskSpec: spec, coordinatorHandle: 'term_coordinator', workerHandle: 'term_worker' @@ -31,7 +30,6 @@ describe('worker-start prompt budget', () => { const prompt = buildDispatchPreamble({ taskId: `task_${'a'.repeat(32)}`, dispatchId: `ctx_${'b'.repeat(32)}`, - dispatchCapability: `dcap_${'C'.repeat(43)}`, taskSpec: 'x'.repeat(ORCHESTRATION_WORKER_START_TASK_SPEC_MAX_BYTES), coordinatorHandle: `term_${'d'.repeat(256)}`, workerHandle: `term_${'e'.repeat(256)}`, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts index 526b09a6320c..cba8baad45ff 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts @@ -213,7 +213,6 @@ describe('orchestration worker-start prompt contract', () => { expect(persisted.getTask(harness.taskId)?.status).toBe('dispatched') expect(persisted.getDispatchContextById(dispatchId)).toMatchObject({ status: 'dispatched', - capability_hash: expect.any(String), capability_revoked_at: null }) expect(persisted.getWorkerDispatch(dispatchId)).toMatchObject({ @@ -276,7 +275,6 @@ describe('orchestration worker-start prompt contract', () => { expect(await pending).toMatchObject({ ok: true, result: { state: 'outcome_unknown' } }) expect(harness.db.findActiveDispatchForAssignee(harness.handle)).toMatchObject({ status: 'pending', - capability_hash: expect.any(String), capability_revoked_at: null }) expect(harness.submittedTurns()).toBe(1) @@ -305,7 +303,7 @@ describe('orchestration worker-start prompt contract', () => { expect(harness.db.getQuestion(questionId)?.status).toBe('pending') }) - it('reports a swallowed Enter as start_unknown while keeping the worker and its capability', async () => { + it('reports a swallowed Enter as start_unknown while keeping the worker reportable', async () => { vi.useFakeTimers() const harness = await createPromptContractHarness('swallowed') const pending = harness.dispatcher.dispatch(harness.request) @@ -343,9 +341,8 @@ describe('orchestration worker-start prompt contract', () => { expect(persisted.getDispatchContextById(dispatchId)).toMatchObject({ status: 'pending', last_failure: null, - // The capability survives so a worker that recovers can still report; worker-report + // Lifecycle stays open so a worker that recovers can still report; worker-report // settlement reconnects a start_unknown worker through 'ready'. - capability_hash: expect.any(String), capability_revoked_at: null }) expect(persisted.getWorkerDispatch(dispatchId)).toMatchObject({ diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts index e39a06110dcd..ceeae2710d4d 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts @@ -42,7 +42,6 @@ function settle(delivered: 'accepted' | undefined) { structuredSession: { host, identity: { sessionId: 's1' } }, terminalHandle: 'structured_worker_1', coordinatorHandle: 'term_c', - dispatchCapability: 'capability', devMode: undefined, requestId: 'r1', agent: 'claude', diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts index 7e9118daffb5..98ddbecd6030 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts @@ -31,7 +31,6 @@ export async function deliverAndSettleWorkerStartReadiness(args: { structuredSession: Awaited> | null terminalHandle: string coordinatorHandle: string - dispatchCapability: string devMode: boolean | undefined requestId: string agent: string | null @@ -56,7 +55,6 @@ export async function deliverAndSettleWorkerStartReadiness(args: { taskId: task.id, taskSpec: task.spec, coordinatorHandle: args.coordinatorHandle, - dispatchCapability: args.dispatchCapability, devMode: args.devMode, requestId: args.requestId }) @@ -89,7 +87,7 @@ export async function deliverAndSettleWorkerStartReadiness(args: { const currentWorker = db.getWorkerDispatch(args.dispatchId) const alreadySettled = currentWorker && currentWorker.state !== 'starting' if (turnStart.verdict === 'unobserved' && !alreadySettled) { - // Honest `unverifiable`: keep the dispatch capability and the terminal — the worker may + // Honest `unverifiable`: keep lifecycle authority and the terminal — the worker may // still recover and report (worker-report settlement reconnects a start_unknown worker) — // but never claim ready for a turn nobody observed. effects.push({ diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-receipt.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-receipt.ts index f91ff690922e..e7d6b0d31edd 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-receipt.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-receipt.ts @@ -1,5 +1,4 @@ import type { OrchestrationDb } from '../../../../orchestration/db' -import { isAgentPromptStalledError } from '../../../../agent-prompt-submission-verification' import { isUnknownWorkerStartOutcome, type WorkerSetupReceipt } from './worker-topology' import type { OrchestrationWorkerLaunchReceipt } from './worker-launch-preferences' import type { WorkerStartModeReceipt } from '../../orchestration-worker-start-mode' @@ -20,11 +19,7 @@ export function failWorkerStartWithReceipt(args: { const unknown = isUnknownWorkerStartOutcome(args.error, args.failedStage) const worker = unknown ? args.db.markWorkerStartUnknown(args.dispatchId, args.failedStage, reason) - : args.db.failWorkerStart(args.dispatchId, args.failedStage, reason, { - // Why (#16095): the preamble is written before submission is verified, so a stalled - // verdict never means the worker lacks its task — keep the authority its report needs. - retainCapability: isAgentPromptStalledError(args.error) - }) + : args.db.failWorkerStart(args.dispatchId, args.failedStage, reason) // Only name cleanup this start actually left behind: a terminal it created and still owns. A // structured session is discarded by the teardown, a pane the user typed into is theirs, and an // unknown outcome is not settled — none of the three has anything for `worker-release` to close. diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-stop-liveness-verdict.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-stop-liveness-verdict.test.ts index c8dec854e4c0..fa35717998d0 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-stop-liveness-verdict.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-stop-liveness-verdict.test.ts @@ -229,7 +229,7 @@ describe('worker-stop against a terminal we lost contact with', () => { const dispatch = createWorker() expect(db.beginWorkerStop(dispatch.id, runtime.getRuntimeId()).disposition).toBe('stopping') - expect(() => db.abandonWorkerDispatch(dispatch.id)).toThrow( + expect(() => db.abandonWorkerDispatch(dispatch.id, runtime.getRuntimeId())).toThrow( 'is stopping; wait for worker-stop to settle before abandoning' ) expect(db.getWorkerDispatch(dispatch.id)?.state).toBe('stopping') diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts index cda3354e8165..fc7c1f3b26cf 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts @@ -224,7 +224,7 @@ describe('orchestration new-worktree workers', () => { expect(db.getDispatchContext(task.id)).toBeUndefined() }) - it('injects the execution host CLI command and Dispatch capability together', async () => { + it('injects the execution host CLI command without a Dispatch capability', async () => { mockCreatedWorktree() vi.mocked(runtime.getTerminalOrchestrationCliCommand).mockReturnValue('orca-ide') @@ -232,7 +232,6 @@ describe('orchestration new-worktree workers', () => { const prompt = vi.mocked(runtime.sendTerminalAgentPrompt).mock.calls[0]?.[1] ?? '' expect(prompt).toContain('orca-ide orchestration send') - expect(prompt).toMatch(/--dispatch-capability dcap_[A-Za-z0-9_-]+/) expect(prompt).not.toMatch(/(^|\s)orca orchestration send/) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts index 554c0912ff03..a1f05657e325 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts @@ -52,7 +52,7 @@ describe('admission revoked while a session is still open', () => { }) expect(response).toMatchObject({ ok: true, result: { ok: true } }) - expect(hostCalls.close).toHaveBeenCalledWith(SESSION) + expect(hostCalls.close).toHaveBeenCalledWith(SESSION, 'user-close') // The durable tab has to be retired too, or the chat comes back on the next sync. expect(hostCalls.setSessionTabVisibility).toHaveBeenCalledWith(SESSION, false) }) @@ -80,7 +80,7 @@ describe('admission revoked while a session is still open', () => { ) expect(response).toMatchObject({ ok: true }) - expect(hostCalls.close).toHaveBeenCalledWith(SESSION) + expect(hostCalls.close).toHaveBeenCalledWith(SESSION, 'user-close') } ) @@ -93,7 +93,7 @@ describe('admission revoked while a session is still open', () => { ) expect(response).toMatchObject({ ok: true }) - expect(hostCalls.close).toHaveBeenCalledWith(SESSION) + expect(hostCalls.close).toHaveBeenCalledWith(SESSION, 'user-close') }) it.each(ADMISSION_METHODS)( diff --git a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts index 90700a5a8179..64c7ec1c8b18 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts @@ -103,7 +103,7 @@ beforeEach(async () => { afterEach(async () => { setStructuredAgentSessionHost(null) await host?.flushAllStreamedEvents() - await host?.close(SESSION) + await host?.close(SESSION, 'evict') await rm(root, { recursive: true, force: true }) vi.restoreAllMocks() vi.unstubAllEnvs() diff --git a/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts index 8a9e2267a5a8..34f8aa8220d6 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts @@ -1,9 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionBackgroundTaskState } from '../../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../../shared/agent-status-child-work-view' import { AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY } from '../../../../shared/protocol-version' +import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from '../../../../shared/agent-session-background-task-child-views-capability' import { remoteRuntimeClientCapabilities } from '../../../../shared/remote-runtime-client-capabilities' import type { AgentSessionSubscribeInput } from '../../../native-chat/agent-session-wire/structured-agent-session-subscribers' import { @@ -170,4 +172,81 @@ describe('background-task stop capability at the RPC boundary', () => { ).toMatchObject({ ok: true, result: { page: { backgroundTasks: stoppable } } }) } }) + + it('advertises reading child views on remote requests and subscriptions', () => { + expect(CURRENT_CLIENT.clientCapabilities).toContain( + AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY + ) + }) +}) + +describe('child views at the RPC boundary', () => { + const view = (membership: 'live' | 'settled'): AgentChildWorkView => ({ + id: `child-${membership}`, + providerId: `task-${membership}`, + kind: 'agent', + state: membership === 'live' ? 'working' : 'done', + membership, + ...(membership === 'settled' ? { outcome: 'succeeded' as const, settledAt: 2 } : {}), + firstObservedAt: 1, + observedAt: 2, + stoppable: true, + invocation: { invocationId: `toolu-${membership}`, generation: 1 } + }) + const LIVE_ROW = { id: 'task-live', kind: 'agent', state: 'working' } as const + const SETTLED_ROW = { id: 'task-settled', kind: 'agent', state: 'done' } as const + /** Predates child views: reads any roster as live work. */ + const PRE_VIEWS_CLIENT = { + ...STRUCTURED_CLIENT, + clientCapabilities: remoteRuntimeClientCapabilities( + STRUCTURED_CLIENT.clientCapabilities + ).filter((capability) => capability !== AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY) + } + + async function historyFor( + backgroundTasks: AgentSessionBackgroundTaskState, + client: Parameters[2] + ) { + hostCalls.history.mockReturnValue({ ok: true, page: { items: [], backgroundTasks } }) + return call('agentSession.history', { sessionId: SESSION, direction: 'tail' }, client) + } + + it('hands a reader that predates views its live roster, without the views', async () => { + const mixed: AgentSessionBackgroundTaskState = { + state: 'monitoring', + supportsTaskStop: true, + tasks: [LIVE_ROW], + settledTasks: [SETTLED_ROW], + children: [view('live'), view('settled')] + } + const result = await historyFor(mixed, PRE_VIEWS_CLIENT) + expect(result).toMatchObject({ + ok: true, + result: { + page: { + backgroundTasks: { + state: 'monitoring', + supportsTaskStop: true, + tasks: [LIVE_ROW], + settledTasks: [SETTLED_ROW] + } + } + } + }) + expect(JSON.stringify(result)).not.toContain('children') + }) + + // A reader draws a per-row stop only when the host offers a targeted one, so a roster with no + // targeted stop (a Codex child, a persistent command) has no dead button to withhold. + it('keeps unstoppable rows for a stop-only reader when no targeted stop is offered', async () => { + const codexRoster: AgentSessionBackgroundTaskState = { + state: 'monitoring', + supportsStopAll: false, + tasks: [{ id: 'codex-agent:thread-a', kind: 'agent', state: 'working', stoppable: false }] + } + expect(await historyFor(codexRoster, STOP_ONLY_CLIENT)).toMatchObject({ + ok: true, + result: { page: { backgroundTasks: codexRoster } } + }) + }) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.ts b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.ts index ae69de4a069d..1e32325e53b5 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.ts @@ -7,6 +7,7 @@ import { AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY } from '../../../../shared/protocol-version' +import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from '../../../../shared/agent-session-background-task-child-views-capability' import type { RpcContext } from '../core' type BackgroundTaskReader = Pick @@ -25,15 +26,33 @@ function honoursRowStop(ctx: BackgroundTaskReader): boolean { ) } +function readsChildViews(ctx: BackgroundTaskReader): boolean { + return ( + ctx.clientKind === undefined || + ctx.clientCapabilities?.includes(AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY) === true + ) +} + +/** A reader that predates child views never gets the views it cannot read. Every roster it is + * handed lists running work (`tasks`), so its reading of any roster as live work holds. Deleted + * with the legacy shapes: see `structured-agent-session-child-work-legacy`. */ +function withoutChildViews( + state: AgentSessionBackgroundTaskState +): AgentSessionBackgroundTaskState { + const { children: _children, ...legacy } = state + return legacy +} + /** A reader that predates `stoppable` draws a per-row stop on every row it is * handed, and the host cannot honour one on a row marked unstoppable — the * dead button the field exists to remove. The host publishing such rows at all * is new, so withholding them hands that reader exactly its pre-feature view; - * a state whose every row is withheld becomes no strip, as it was. */ + * a state whose every row is withheld becomes no strip, as it was. A host that + * offers no targeted stop draws no per-row stop, so it has nothing to withhold. */ function withoutUnstoppableRows( state: AgentSessionBackgroundTaskState ): AgentSessionBackgroundTaskState | null { - if (!state.tasks?.some((task) => task.stoppable === false)) { + if (!state.supportsTaskStop || !state.tasks?.some((task) => task.stoppable === false)) { return state } const tasks = state.tasks.filter((task) => task.stoppable !== false) @@ -44,7 +63,8 @@ function projectState( state: AgentSessionBackgroundTaskState | null | undefined, ctx: BackgroundTaskReader ): AgentSessionBackgroundTaskState | null | undefined { - const rows = !state || honoursRowStop(ctx) ? state : withoutUnstoppableRows(state) + const views = !state || readsChildViews(ctx) ? state : withoutChildViews(state) + const rows = !views || honoursRowStop(ctx) ? views : withoutUnstoppableRows(views) // Legacy readers always offer a stop; retain their pre-producer empty strip. return rows?.supportsStopAll === false && !rows.supportsTaskStop && !supportsReadOnlyTasks(ctx) ? null diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts index 000192cc183d..45c01c2c63ac 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts @@ -123,7 +123,7 @@ afterEach(async () => { describe('the hold surface, for clients that still call it', () => { it('answers a hold without starting an agent or registering a cleanup', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') expect(host.hasSession(SESSION)).toBe(false) const registered = vi.spyOn(runtime, 'registerOwnedSubscriptionCleanup') const acquiresBefore = acquire.mock.calls.length @@ -163,7 +163,7 @@ describe('the hold surface, for clients that still call it', () => { }) it('answers a hold even when no agent could be started', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') acquire.mockRejectedValue(new Error('provider unavailable')) const acquiresBefore = acquire.mock.calls.length @@ -176,7 +176,7 @@ describe('the hold surface, for clients that still call it', () => { describe('a stream', () => { it('reads a closed conversation without starting its agent', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') expect(host.hasSession(SESSION)).toBe(false) const acquiresBefore = acquire.mock.calls.length const frames: unknown[] = [] diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index d9d430f08e39..cc0677b134a5 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -133,7 +133,7 @@ describe('capability gating', () => { const response = await call('agentSession.close', { sessionId: SESSION }, STRUCTURED_CLIENT) expect(response).toMatchObject({ ok: true, result: { ok: true } }) - expect(hostCalls.close).toHaveBeenCalledWith(SESSION) + expect(hostCalls.close).toHaveBeenCalledWith(SESSION, 'user-close') expect(hostCalls.setSessionTabVisibility).toHaveBeenCalledWith(SESSION, false) expect(hostCalls.setSessionTabVisibility.mock.invocationCallOrder[0]).toBeLessThan( hostCalls.close.mock.invocationCallOrder[0]! diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 534d49677793..b97da008a402 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -123,9 +123,9 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ .conversationReplacements() .find((entry) => entry.sourceSessionId === params.envelope.sessionId) if (replacement) { - await ctx.runtime.replaceStructuredAgentSessionTab(replacement) + ctx.runtime.replaceStructuredAgentSessionTab(replacement) } - await host.close(params.envelope.sessionId) + await host.close(params.envelope.sessionId, 'user-close') } return result } @@ -221,7 +221,8 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ if (typeof host.setSessionTabVisibility === 'function') { await host.setSessionTabVisibility(params.sessionId, false) } - await host.close(params.sessionId) + // Clients call this only when the user closes this chat's tab or cancels its launch. + await host.close(params.sessionId, 'user-close') return { ok: true as const } } }), diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts index 29623f623cd0..e8f7108f9f99 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts @@ -3,11 +3,12 @@ * the real structured host, the real runtime, and the real RPC handlers. Only the provider is faked. */ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' +import type { AgentSessionStatusSummary } from '../../../../shared/agent-session-wire' import { CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY @@ -27,7 +28,7 @@ import { setStructuredAgentSessionHost } from '../../../native-chat/agent-sessio import type { AgentSessionRecordStore } from '../../agent-session-record-store' import { openTestAgentSessionRecordStore, - testAgentSessionStoreFilePath + seedTestAgentSessionStoreFromNewerBuild } from '../../agent-session-record-store-test-harness' import { OrcaRuntimeService } from '../../orca-runtime' import { RpcDispatcher } from '../dispatcher' @@ -193,6 +194,7 @@ beforeEach(async () => { }) afterEach(async () => { + vi.restoreAllMocks() await host?.flushAllStreamedEvents() setStructuredAgentSessionHost(null) await rm(directory, { recursive: true, force: true }) @@ -260,9 +262,22 @@ describe('a chat tab across /clear', () => { expect(await send(replacement, 'still here')).toMatchObject({ ok: true, result: { ok: true } }) }) + // A worktree delete closes every chat in it; one that never started must not ask to be forced. + it('closes a replacement that never started as settled', async () => { + await createChat(HOST_TEST_SESSION) + const replacement = await clear(HOST_TEST_SESSION) + expect(await closeStructuredAgentSessionChild(replacement)).toEqual({ + stopped: true, + closeAttempted: true + }) + }) + it('puts a cleared chat back under the tab id it had when its close does not land', async () => { await createChat(HOST_TEST_SESSION) const replacement = await clear(HOST_TEST_SESSION) + // Its agent starts on its first message; only a running agent has a close that can fail. + expect(await send(replacement, 'first message')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(store.getRecord(replacement)?.lease.claimStatus).toBe('live')) closeSession.mockResolvedValue(false) const outcome = await closeStructuredAgentSessionChild(replacement) @@ -283,26 +298,85 @@ describe('a chat tab across /clear', () => { sessionIds: [replacement] }) }) +}) + +describe('other clients after a /clear from this window', () => { + const STRUCTURED = [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ] + const clients = { + window: { clientKind: 'runtime' as const, clientCapabilities: STRUCTURED }, + phone: { clientKind: 'mobile' as const, clientCapabilities: STRUCTURED } + } + + /** The chat tabs a client lists, as that client is sent them. */ + async function listedChats(context: RpcDispatchStreamingOptions) { + const response = await dispatcher.dispatch( + { + id: 'list', + authToken: 'token', + method: 'session.tabs.list', + params: { worktree: WORKTREE } + }, + context + ) + const listed: { result?: { tabs?: { type: string }[] } } = JSON.parse(JSON.stringify(response)) + return (listed.result?.tabs ?? []).filter((tab) => tab.type === 'agent-session') + } + + /** The session list each client keeps: the latest status the host published per session. */ + function sessionList(id: string): Map { + const latest = new Map() + host.subscribeStatus({ + id, + emit: (event) => { + if (event.type === 'snapshot') { + event.sessions.forEach((session) => latest.set(session.sessionId, session)) + } else if (event.type === 'status') { + latest.set(event.session.sessionId, event.session) + } + } + }) + return latest + } - it('gives a reopened cleared conversation the same id when an older build drops the table', async () => { + it('shows another window and a phone the replacement the marker names, with nothing unread', async () => { await createChat(HOST_TEST_SESSION) - const first = await clear(HOST_TEST_SESSION) - await call('agentSession.reveal', { sessionId: HOST_TEST_SESSION }) - const current = await clear(first) - const reopenedTab = store.getSessionTabId(HOST_TEST_SESSION) - expect(reopenedTab).not.toBeNull() - await host.flushAllStreamedEvents() + const lists = { window: sessionList('window-b'), phone: sessionList('phone') } + const replacement = await clear(HOST_TEST_SESSION) + // The stored pointer is the only source of the id: nothing a client holds can derive it. + expect(store.getRecord(HOST_TEST_SESSION)?.conversationCommand).toMatchObject({ + phase: 'committed', + replacementSessionId: replacement + }) + expect(store.getSessionTabId(replacement)).toBe(SOURCE_TAB) - // An older build rewrites the file from what it read, which drops the table. - const file = testAgentSessionStoreFilePath(directory) - const raw = JSON.parse(await readFile(file, 'utf-8')) - expect(raw.sessionTabs).toHaveLength(2) - delete raw.sessionTabs - await writeFile(file, JSON.stringify(raw)) + for (const context of Object.values(clients)) { + expect(await listedChats(context)).toEqual([ + expect.objectContaining({ sessionId: replacement, replacesSessionId: HOST_TEST_SESSION }) + ]) + } + // Never started, so no client lists a turn for it to be unread. + for (const list of Object.values(lists)) { + expect(list.get(replacement)?.status ?? null).toBeNull() + expect(list.get(replacement)?.turnOutcome).toBeUndefined() + } - await openHost() - expect(store.getSessionTabId(current)).toBe(SOURCE_TAB) - expect(store.getSessionTabId(HOST_TEST_SESSION)).toBe(reopenedTab) + expect(await send(replacement, 'first message')).toMatchObject({ + ok: true, + result: { ok: true } + }) + await vi.waitFor(() => { + for (const list of Object.values(lists)) { + expect(list.get(replacement)).toMatchObject({ sessionId: replacement, status: 'working' }) + } + }) + for (const context of Object.values(clients)) { + expect(await listedChats(context)).toEqual([ + expect.objectContaining({ sessionId: replacement }) + ]) + } }) }) @@ -408,3 +482,47 @@ describe('a create that reserves its tab', () => { }) }) }) + +describe('a chat tab over records a newer Orca wrote', () => { + it('opens a closed chat from history for reading', async () => { + await createChat(HOST_TEST_SESSION) + await host.close(HOST_TEST_SESSION, 'user-close') + await host.setSessionTabVisibility(HOST_TEST_SESSION, false) + await host.flushAllStreamedEvents() + await seedTestAgentSessionStoreFromNewerBuild(directory) + await openHost() + expect(store.readOnly).toBe(true) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + expect(await call('agentSession.reveal', { sessionId: HOST_TEST_SESSION })).toMatchObject({ + ok: true, + result: { ok: true, readable: true } + }) + + expect((await snapshot()).activeTabId).toBe(`agent-session:${HOST_TEST_SESSION}`) + expect(warn).toHaveBeenCalledWith( + '[structured-agent-session] recording an opened chat tab failed', + expect.anything() + ) + }) +}) + +describe('a chat tab whose restore index cannot be written', () => { + // The index is bookkeeping: the chat is created and its tab opens, but no restart restores it. + it('creates the chat and opens its tab, reporting the index write', async () => { + const failure = new Error('disk I/O error') + vi.spyOn(store, 'setSessionTabVisibility').mockRejectedValue(failure) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + const created = await createChat(HOST_TEST_SESSION) + + expect(created).toMatchObject({ ok: true, value: { sessionId: HOST_TEST_SESSION } }) + expect(created.ok ? created.value.tabId : null).toBeUndefined() + expect((await snapshot()).activeTabId).toBe(`agent-session:${HOST_TEST_SESSION}`) + expect(warn).toHaveBeenCalledWith( + '[structured-agent-session] recording an opened chat tab failed', + failure + ) + expect(store.getSessionTabId(HOST_TEST_SESSION)).toBeNull() + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts b/src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts index aebb420fa8b4..d6f41acdeee9 100644 --- a/src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts +++ b/src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts @@ -113,7 +113,7 @@ describe('worker-stop on a structured worker this runtime cannot reach', () => { state: 'stop_unknown' } ) - expect(close).toHaveBeenCalledWith(SESSION) + expect(close).toHaveBeenCalledWith(SESSION, 'evict') expect(db.getWorkerDispatch(dispatchId)?.state).toBe('stop_unknown') }) diff --git a/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts b/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts index b48dccf52a39..cee7adf64256 100644 --- a/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts +++ b/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts @@ -323,7 +323,7 @@ describe('structured worker discard retires the chat tab', () => { }) ).rejects.toThrow(/was refused/) - expect(close).toHaveBeenCalledWith(createdSessionId) + expect(close).toHaveBeenCalledWith(createdSessionId, 'evict') expect(await structuredTabIds(runtime)).toEqual([]) }) }) diff --git a/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts b/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts index 5f077b9d204a..fd8235f51742 100644 --- a/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts +++ b/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts @@ -14,6 +14,7 @@ type MainOwnedUIState = | 'dashboardPopoutBounds' | '_expandedWorktreeCardPropertiesDefaulted' | '_jiraIssueWorktreeCardPropertyDefaulted' + | '_hostWorktreeCardPropertyDefaulted' | 'starNagBaselineAgents' | 'starNagAppVersion' | 'starNagNextThreshold' diff --git a/src/main/runtime/rpc/methods/worktree-catalog-methods.ts b/src/main/runtime/rpc/methods/worktree-catalog-methods.ts index 8b230c169d8e..082d0400f7c8 100644 --- a/src/main/runtime/rpc/methods/worktree-catalog-methods.ts +++ b/src/main/runtime/rpc/methods/worktree-catalog-methods.ts @@ -1,6 +1,11 @@ import { defineMethod } from '../core' import { resolveWorktreeCatalogSnapshot } from '../worktree-catalog-snapshot' import { supportsWorktreeVisibilitySourceDefaults } from '../worktree-visibility-client-capability' +import { + projectWorktreeListRemovals, + projectWorktreePsRemovals +} from '../worktree-removal-marker-projection' +import { snapshotPendingWorktreeRemovals } from '../../../worktree-background-removal' import { WorktreeDetectedListParams, WorktreeListParams, @@ -12,12 +17,17 @@ export const WORKTREE_CATALOG_METHODS = [ name: 'worktree.ps', params: WorktreePsParams, handler: async (params, context) => { - const result = await context.runtime.getWorktreePs( - params.limit, - supportsWorktreeVisibilitySourceDefaults( - context, - params.supportsWorktreeVisibilitySourceDefaults - ) + const pendingAtScan = snapshotPendingWorktreeRemovals() + const result = projectWorktreePsRemovals( + await context.runtime.getWorktreePs( + params.limit, + supportsWorktreeVisibilitySourceDefaults( + context, + params.supportsWorktreeVisibilitySourceDefaults + ) + ), + context, + pendingAtScan ) // Why: callers that never send the field get the byte-exact legacy response. return params.afterSnapshotId === undefined @@ -28,12 +38,18 @@ export const WORKTREE_CATALOG_METHODS = [ defineMethod({ name: 'worktree.list', params: WorktreeListParams, - handler: async (params, context) => - context.runtime.listManagedWorktrees( - params.repo, - params.limit, - supportsWorktreeVisibilitySourceDefaults(context) + handler: async (params, context) => { + const pendingAtScan = snapshotPendingWorktreeRemovals() + return projectWorktreeListRemovals( + await context.runtime.listManagedWorktrees( + params.repo, + params.limit, + supportsWorktreeVisibilitySourceDefaults(context) + ), + context, + pendingAtScan ) + } }), defineMethod({ name: 'worktree.listRetiredNames', @@ -43,11 +59,17 @@ export const WORKTREE_CATALOG_METHODS = [ defineMethod({ name: 'worktree.detectedList', params: WorktreeDetectedListParams, - handler: async (params, context) => - context.runtime.listDetectedManagedWorktrees( - params.repo, - undefined, - supportsWorktreeVisibilitySourceDefaults(context) + handler: async (params, context) => { + const pendingAtScan = snapshotPendingWorktreeRemovals() + return projectWorktreeListRemovals( + await context.runtime.listDetectedManagedWorktrees( + params.repo, + undefined, + supportsWorktreeVisibilitySourceDefaults(context) + ), + context, + pendingAtScan ) + } }) ] diff --git a/src/main/runtime/rpc/methods/worktree-rm-background-reply.test.ts b/src/main/runtime/rpc/methods/worktree-rm-background-reply.test.ts new file mode 100644 index 000000000000..1905d95b2951 --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-rm-background-reply.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest' +import { + ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, + WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY, + type RuntimeCapability +} from '../../../../shared/protocol-version' +import { RpcDispatcher } from '../dispatcher' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { WORKTREE_METHODS } from './worktree' + +function makeRuntime(): OrcaRuntimeService { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: worktree.rm with an explicit host reads only removeManagedWorktree. + return { + getRuntimeId: () => 'test-runtime', + removeManagedWorktree: vi.fn().mockResolvedValue({}) + } as unknown as OrcaRuntimeService +} + +async function dispatchRm( + runtime: OrcaRuntimeService, + clientCapabilities: readonly RuntimeCapability[] | undefined +): Promise { + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + await dispatcher.dispatch( + { + id: 'req-1', + authToken: 'tok', + method: 'worktree.rm', + params: { worktree: 'id:wt-1', hostId: 'local' } + }, + { clientCapabilities } + ) +} + +// Why: the reply may wait out Git's delete only for a client that shows the `removing` marker; an +// older client is answered on acceptance, as its timeouts and listings expect. +describe('worktree.rm waits for the delete only for clients that can show it', () => { + it('asks the runtime to wait for a client that advertises background removal', async () => { + expect(ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES).toContain( + WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY + ) + const runtime = makeRuntime() + await dispatchRm(runtime, ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES) + expect(runtime.removeManagedWorktree).toHaveBeenCalledWith( + 'id:wt-1', + expect.objectContaining({ waitForBackgroundRemoval: true }) + ) + }) + + it.each([ + ['a client that negotiated no capabilities', undefined], + ['a client without background removal', []] + ])('answers %s on acceptance', async (_name, capabilities) => { + const runtime = makeRuntime() + await dispatchRm(runtime, capabilities) + expect(runtime.removeManagedWorktree).toHaveBeenCalledWith( + 'id:wt-1', + expect.not.objectContaining({ waitForBackgroundRemoval: true }) + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index c7dda776333c..468a1235a33c 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -26,6 +26,7 @@ import { WorktreeTeardownMissingTerminalsParams } from './worktree-schemas' import { WORKTREE_CATALOG_METHODS } from './worktree-catalog-methods' +import { readsWorktreeRemovalMarker } from '../worktree-removal-marker-projection' export const WORKTREE_METHODS = [ ...WORKTREE_CATALOG_METHODS, @@ -215,7 +216,8 @@ export const WORKTREE_METHODS = [ defineMethod({ name: 'worktree.rm', params: WorktreeRemove, - handler: async (params, { runtime }) => { + handler: async (params, context) => { + const { runtime } = context // Translate a paired client's runtime-local host spelling before host-qualified reads. let resolvedHostId = resolvePairedCallerHostId( () => runtime.listRepos(), @@ -250,7 +252,10 @@ export const WORKTREE_METHODS = [ runHooks: params.runHooks === true, allowUnverifiedPtyStop: params.allowUnverifiedPtyStop === true, allowFailedArchiveHook: params.allowFailedArchiveHook === true, - ...(resolvedHostId ? { hostId: resolvedHostId } : {}) + ...(resolvedHostId ? { hostId: resolvedHostId } : {}), + // Why: only a client that shows the `removing` marker can wait out Git's delete; older + // clients get the acceptance and never see the row again. + ...(readsWorktreeRemovalMarker(context) ? { waitForBackgroundRemoval: true } : {}) }) return { removed: true, diff --git a/src/main/runtime/rpc/orchestration-commit-notify-characterization.test.ts b/src/main/runtime/rpc/orchestration-commit-notify-characterization.test.ts index a990fa9905b7..4ebd49df17e6 100644 --- a/src/main/runtime/rpc/orchestration-commit-notify-characterization.test.ts +++ b/src/main/runtime/rpc/orchestration-commit-notify-characterization.test.ts @@ -49,7 +49,7 @@ describe('orchestration commit-notify recovery', () => { taskId, startOptions: {} }) - const capability = db.prepareStartingWorkerAuthority({ + db.prepareStartingWorkerAuthority({ dispatchId: started.dispatch.id, handle: 'term_worker', paneKey: workerPaneKey, @@ -59,7 +59,7 @@ describe('orchestration commit-notify recovery', () => { setupState: 'not_applicable' }) db.markWorkerDispatchReady(started.dispatch.id) - return { dispatch: db.getDispatchContextById(started.dispatch.id)!, capability } + return { dispatch: db.getDispatchContextById(started.dispatch.id)! } } async function throwAfterCommitAndReplay( @@ -201,7 +201,7 @@ describe('orchestration commit-notify recovery', () => { : null ) const task = db.createTask({ spec: 'Settle once' }) - const { dispatch, capability } = createReadyLocalWorker(db, task.id, workerPaneKey) + const { dispatch } = createReadyLocalWorker(db, task.id, workerPaneKey) const dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) const workerDone = request('rpc_worker_done', 'mutation_worker_done', 'orchestration.send', { from: 'term_worker', @@ -213,7 +213,6 @@ describe('orchestration commit-notify recovery', () => { outcome: 'succeeded' }) }) - workerDone.orchestrationCapability = capability const waiting = runtime.waitForMessage(`run:${activeRunId}`, { typeFilter: ['worker_done'], timeoutMs: 5_000 @@ -270,7 +269,7 @@ describe('orchestration commit-notify recovery', () => { handle.startsWith('term_') ? `runtime_test:${handle}:1` : null ) const task = db.createTask({ spec: 'Resume before atomic settlement', runId: run.id }) - const { dispatch, capability } = createReadyLocalWorker(db, task.id, workerPaneKey) + const { dispatch } = createReadyLocalWorker(db, task.id, workerPaneKey) const workerDone = request( 'rpc_worker_done_before_crash', 'mutation_worker_done_before_crash', @@ -286,7 +285,6 @@ describe('orchestration commit-notify recovery', () => { }) } ) - workerDone.orchestrationCapability = capability vi.spyOn(db, 'commitWorkerDoneMessageMutation').mockImplementationOnce(() => { throw new OrchestrationError( 'operation_unknown', @@ -384,7 +382,7 @@ describe('orchestration commit-notify recovery', () => { : null ) const task = db.createTask({ spec: 'Commit report and settlement together' }) - const { dispatch, capability } = createReadyLocalWorker(db, task.id, workerPaneKey) + const { dispatch } = createReadyLocalWorker(db, task.id, workerPaneKey) const dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) const workerDone = request( 'rpc_atomic_worker_done', @@ -401,7 +399,6 @@ describe('orchestration commit-notify recovery', () => { }) } ) - workerDone.orchestrationCapability = capability const callerFingerprint = db.getOrCreateLocalMutationCallerFingerprint() inject(db) diff --git a/src/main/runtime/rpc/orchestration-current-authority-precedence.test.ts b/src/main/runtime/rpc/orchestration-current-authority-precedence.test.ts index 41e40aff7a07..d13a823ba46a 100644 --- a/src/main/runtime/rpc/orchestration-current-authority-precedence.test.ts +++ b/src/main/runtime/rpc/orchestration-current-authority-precedence.test.ts @@ -125,48 +125,44 @@ describe('current orchestration authority precedence', () => { it.each([ ['heartbeat', undefined], ['worker_done', 'succeeded'] - ] as const)( - 'routes a capability-backed current %s before legacy fallback', - async (type, outcome) => { - const harness = createHarness() - const { taskId, dispatchId, capability } = createCurrentDispatch(harness) - const payload = JSON.stringify({ - taskId, - dispatchId, - ...(outcome ? { outcome } : {}) - }) - const response = await harness.dispatcher.dispatch({ - ...request( - 'orchestration.send', - { - from: CURRENT_WORKER_HANDLE, - subject: type === 'heartbeat' ? 'alive' : 'Completed', - type, - payload - }, - currentEvidence('worker'), - `current-${type}` - ), - orchestrationCapability: capability - }) + ] as const)('routes a current %s before legacy fallback', async (type, outcome) => { + const harness = createHarness() + const { taskId, dispatchId } = createCurrentDispatch(harness) + const payload = JSON.stringify({ + taskId, + dispatchId, + ...(outcome ? { outcome } : {}) + }) + const response = await harness.dispatcher.dispatch({ + ...request( + 'orchestration.send', + { + from: CURRENT_WORKER_HANDLE, + subject: type === 'heartbeat' ? 'alive' : 'Completed', + type, + payload + }, + currentEvidence('worker'), + `current-${type}` + ) + }) - expect(response).toMatchObject({ - ok: true, - result: { - message: { - from_handle: CURRENT_WORKER_HANDLE, - to_handle: expect.stringMatching(/^run:/), - type, - delivery_contract: 'current_delivery' - } + expect(response).toMatchObject({ + ok: true, + result: { + message: { + from_handle: CURRENT_WORKER_HANDLE, + to_handle: expect.stringMatching(/^run:/), + type, + delivery_contract: 'current_delivery' } - }) - expect(response).not.toHaveProperty('result.legacyCompatibility') - expect(harness.db.getTask(taskId)?.status).toBe( - type === 'worker_done' ? 'completed' : 'dispatched' - ) - } - ) + } + }) + expect(response).not.toHaveProperty('result.legacyCompatibility') + expect(harness.db.getTask(taskId)?.status).toBe( + type === 'worker_done' ? 'completed' : 'dispatched' + ) + }) it('routes a current worker mailbox check before legacy fallback', async () => { const harness = createHarness() @@ -273,7 +269,7 @@ describe('current orchestration authority precedence', () => { '%s routes a reused legacy terminal current ask before its retained principal', async (transport) => { const harness = createHarness() - const { dispatchId, capability } = await createReusedCurrentDispatch(harness, transport) + const { dispatchId } = await createReusedCurrentDispatch(harness, transport) const response = await invoke( harness.dispatcher, @@ -287,8 +283,7 @@ describe('current orchestration authority precedence', () => { }, evidence('worker'), `reused-current-ask-${transport}` - ), - orchestrationCapability: capability + ) }, transport ) @@ -312,7 +307,6 @@ function createCurrentDispatch(harness: ReturnType): { runId: string taskId: string dispatchId: string - capability: string } { const run = harness.db.createRun({ objective: 'current work', @@ -324,14 +318,11 @@ function createCurrentDispatch(harness: ReturnType): { harness.db, task.id, CURRENT_WORKER_HANDLE, - CURRENT_WORKER_PANE + CURRENT_WORKER_PANE, + undefined, + 'process-1' ) - const capability = harness.db.mintDispatchCapability({ - dispatchId: dispatch.id, - paneKey: CURRENT_WORKER_PANE, - processIncarnation: 'process-1' - }) - return { runId: run.id, taskId: task.id, dispatchId: dispatch.id, capability } + return { runId: run.id, taskId: task.id, dispatchId: dispatch.id } } async function createReusedCurrentDispatch( @@ -341,7 +332,6 @@ async function createReusedCurrentDispatch( runId: string taskId: string dispatchId: string - capability: string }> { const legacyCheck = await harness.dispatcher.dispatch( request( @@ -360,11 +350,13 @@ async function createReusedCurrentDispatch( coordinatorPaneKey: CURRENT_COORDINATOR_PANE }) const task = harness.db.createTask({ spec: 'reused terminal assignment', runId: run.id }) - const dispatch = createRootDispatch(harness.db, task.id, WORKER_HANDLE, WORKER_PANE) - const capability = harness.db.mintDispatchCapability({ - dispatchId: dispatch.id, - paneKey: WORKER_PANE, - processIncarnation: 'process-1' - }) - return { runId: run.id, taskId: task.id, dispatchId: dispatch.id, capability } + const dispatch = createRootDispatch( + harness.db, + task.id, + WORKER_HANDLE, + WORKER_PANE, + undefined, + 'process-1' + ) + return { runId: run.id, taskId: task.id, dispatchId: dispatch.id } } diff --git a/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts b/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts index b87f595f4b24..6909b4f33457 100644 --- a/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts +++ b/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts @@ -377,18 +377,19 @@ describe('durable orchestration mutation ledger', () => { coordinatorPaneKey: 'tab_coord:leaf_coord' }) const task = db.createTask({ spec: 'ask', runId: run.id }) - const dispatch = createRootDispatch(db, task.id, 'term_worker', 'tab_worker:leaf_worker') - const capability = db.mintDispatchCapability({ - dispatchId: dispatch.id, - paneKey: 'tab_worker:leaf_worker', - processIncarnation: 'runtime:pty:1' - }) + createRootDispatch( + db, + task.id, + 'term_worker', + 'tab_worker:leaf_worker', + undefined, + 'runtime:pty:1' + ) const askRequest: RpcRequest = { id: 'rpc_ask_1', authToken: 'caller-token', method: 'orchestration.ask', params: { from: 'term_worker', question: 'Proceed?', timeoutMs: 60_000 }, - orchestrationCapability: capability, orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, orchestrationRequestId: 'mutation_ask' } diff --git a/src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts b/src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts index 4172097853d0..db1da5c93d08 100644 --- a/src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts +++ b/src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts @@ -27,7 +27,6 @@ type Harness = { createDispatcher: () => RpcDispatcher taskId: string dispatchId: string - capability: string adoptedRunId: string markerPath: string } @@ -66,12 +65,18 @@ function createUpdateHarness(): Harness { spec: 'finish work across an app update', createdByTerminalHandle: COORDINATOR_HANDLE }) - const dispatch = createRootDispatch(oldRuntimeDb, task.id, WORKER_HANDLE, WORKER_PANE) - const capability = oldRuntimeDb.mintDispatchCapability({ - dispatchId: dispatch.id, - paneKey: WORKER_PANE, - processIncarnation: PROCESS_INCARNATION - }) + const dispatch = createRootDispatch( + oldRuntimeDb, + task.id, + WORKER_HANDLE, + WORKER_PANE, + undefined, + PROCESS_INCARNATION + ) + // The pre-update runtime minted a capability; its hash is what classifies the row as current. + oldRuntimeDb.db + .prepare("UPDATE dispatch_contexts SET capability_hash = 'minted-before-update' WHERE id = ?") + .run(dispatch.id) oldRuntimeDb.close() const raw = new Database(dbPath) @@ -152,7 +157,6 @@ function createUpdateHarness(): Harness { createDispatcher, taskId: task.id, dispatchId: dispatch.id, - capability, adoptedRunId: adoptedRunId as string, markerPath } @@ -233,7 +237,6 @@ describe('orchestration runtime update settlement', () => { 'worker', 'retained-worker-done' ) - completion.orchestrationCapability = harness.capability const first = await harness.createDispatcher().dispatch(completion) const replay = await harness.createDispatcher().dispatch({ ...completion, id: 'rpc_replay' }) @@ -326,7 +329,6 @@ describe('orchestration runtime update settlement', () => { 'worker', 'forged-worker-done' ) - completion.orchestrationCapability = harness.capability completion.orchestrationCompatibilityEvidence = { ...completion.orchestrationCompatibilityEvidence!, paneKey: 'tab_foreign:99999999-9999-4999-8999-999999999999' @@ -339,8 +341,8 @@ describe('orchestration runtime update settlement', () => { result: { lifecycle: { action: 'rejected', - code: 'dispatch_capability_invalid', - reason: 'The caller is not the Dispatch pane.' + code: 'worker_identity_changed', + reason: `term_pre_update_worker is not the exact process that owns Dispatch ${harness.dispatchId}.` } } }) @@ -392,11 +394,12 @@ describe('orchestration runtime update settlement', () => { const attachment = { dispatch_id: 'dispatch-remote-retained', task_id: 'task-remote-retained', + state: 'ready', process_incarnation: CURRENT_COORDINATOR_PROCESS_INCARNATION } vi.spyOn(harness.db, 'findActiveRemoteAttachmentForPane').mockReturnValue(attachment as never) - const verifyAuthority = vi - .spyOn(harness.db, 'verifyRemoteAttachmentAuthority') + const processCurrent = vi + .spyOn(harness.db, 'isRemoteAttachmentProcessCurrent') .mockReturnValue(true) vi.spyOn(harness.db, 'enqueueFederationRelay').mockReturnValue({ message_id: 'relay-retained-status', @@ -404,8 +407,8 @@ describe('orchestration runtime update settlement', () => { dispatch_id: attachment.dispatch_id } as never) - const response = await harness.createDispatcher().dispatch({ - ...request( + const response = await harness.createDispatcher().dispatch( + request( 'orchestration.send', { from: CURRENT_COORDINATOR_HANDLE, @@ -415,16 +418,14 @@ describe('orchestration runtime update settlement', () => { }, 'current-coordinator', 'retained-remote-status' - ), - orchestrationCapability: 'dcap_remote_retained' - }) + ) + ) expect(resultOf(response)).toMatchObject({ relay: { dispatchId: attachment.dispatch_id, accepted: true } }) - expect(verifyAuthority).toHaveBeenCalledWith({ + expect(processCurrent).toHaveBeenCalledWith({ dispatchId: attachment.dispatch_id, - capability: 'dcap_remote_retained', paneKey: CURRENT_COORDINATOR_PANE, processIncarnation: CURRENT_COORDINATOR_PROCESS_INCARNATION }) diff --git a/src/main/runtime/rpc/orchestration-task-dispatch-invariant.test.ts b/src/main/runtime/rpc/orchestration-task-dispatch-invariant.test.ts index 0d194a372bae..d09c47939f00 100644 --- a/src/main/runtime/rpc/orchestration-task-dispatch-invariant.test.ts +++ b/src/main/runtime/rpc/orchestration-task-dispatch-invariant.test.ts @@ -9,6 +9,7 @@ import { OrchestrationDb } from '../orchestration/db' import type { RpcRequest, RpcResponse } from './core' import { RpcDispatcher } from './dispatcher' import { ORCHESTRATION_METHODS } from './methods/orchestration' +import { reattachDispatchConsumer } from '../orchestration/db/root-dispatch-test-fixture' const COORDINATOR_HANDLE = 'term_invariant_coordinator' const COORDINATOR_PANE = 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' @@ -94,7 +95,7 @@ describe('Task/Dispatch state invariant', () => { it('atomically fails a context-only Dispatch, revokes its capability, and frees the terminal', async () => { const harness = createHarness() const task = harness.db.createTask({ spec: 'failing assignment', runId: harness.runId }) - const { dispatch, capability } = await createCapableDispatch(harness, task.id, 'dispatched') + const { dispatch } = await createCapableDispatch(harness, task.id, 'dispatched') const response = await updateTask(harness, task.id, 'failed', 'coordinator stopped work') @@ -107,14 +108,6 @@ describe('Task/Dispatch state invariant', () => { dispatchCompletedAt: expect.any(String), capabilityRevokedAt: expect.any(String) }) - expect( - harness.db.verifyDispatchCapability({ - dispatchId: dispatch.id, - capability, - paneKey: WORKER_PANE, - processIncarnation: WORKER_PROCESS - }) - ).toEqual({ valid: false, reason: `Dispatch ${dispatch.id} capability is revoked.` }) const laterTask = harness.db.createTask({ spec: 'later assignment', runId: harness.runId }) await expect(dispatchTask(harness, laterTask.id, WORKER_HANDLE)).resolves.toMatchObject({ @@ -132,7 +125,7 @@ describe('Task/Dispatch state invariant', () => { async (dispatchStatus, taskStatus) => { const harness = createHarness() const task = harness.db.createTask({ spec: 'supervised assignment', runId: harness.runId }) - const { dispatch, capability } = createSupervisedDispatch(harness, task.id, dispatchStatus) + const { dispatch } = createSupervisedDispatch(harness, task.id, dispatchStatus) const response = await updateTask(harness, task.id, taskStatus, 'must not persist') @@ -156,14 +149,6 @@ describe('Task/Dispatch state invariant', () => { ownershipState: 'owned', releaseState: 'not_requested' }) - expect( - harness.db.verifyDispatchCapability({ - dispatchId: dispatch.id, - capability, - paneKey: WORKER_PANE, - processIncarnation: WORKER_PROCESS - }) - ).toEqual({ valid: true }) } ) @@ -288,7 +273,7 @@ async function createCapableDispatch( harness: Harness, taskId: string, status: 'pending' | 'dispatched' -): Promise<{ dispatch: { id: string }; capability: string }> { +): Promise<{ dispatch: { id: string } }> { if (status === 'pending') { const dispatch = harness.db.createStartingWorkerDispatch({ creator: { kind: 'system' }, @@ -296,7 +281,7 @@ async function createCapableDispatch( taskId, startOptions: {} }).dispatch - const capability = harness.db.prepareStartingWorkerAuthority({ + harness.db.prepareStartingWorkerAuthority({ dispatchId: dispatch.id, handle: WORKER_HANDLE, paneKey: WORKER_PANE, @@ -306,29 +291,29 @@ async function createCapableDispatch( effects: [], terminalOwnership: 'external' }) - return { dispatch, capability } + return { dispatch } } const dispatch = await dispatchTask(harness, taskId, WORKER_HANDLE) - const capability = harness.db.mintDispatchCapability({ + reattachDispatchConsumer(harness.db, { dispatchId: dispatch.id, paneKey: WORKER_PANE, processIncarnation: WORKER_PROCESS }) - return { dispatch, capability } + return { dispatch } } function createSupervisedDispatch( harness: Harness, taskId: string, status: 'pending' | 'dispatched' -): { dispatch: { id: string }; capability: string } { +): { dispatch: { id: string } } { const dispatch = harness.db.createStartingWorkerDispatch({ creator: { kind: 'system' }, maxDepth: Number.MAX_SAFE_INTEGER, taskId, startOptions: {} }).dispatch - const capability = harness.db.prepareStartingWorkerAuthority({ + harness.db.prepareStartingWorkerAuthority({ dispatchId: dispatch.id, handle: WORKER_HANDLE, paneKey: WORKER_PANE, @@ -341,7 +326,7 @@ function createSupervisedDispatch( if (status === 'dispatched') { harness.db.markWorkerDispatchReady(dispatch.id) } - return { dispatch, capability } + return { dispatch } } function updateTask( diff --git a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts index 6c2faad902e5..07706125c235 100644 --- a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts +++ b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts @@ -141,7 +141,6 @@ export class RpcStreamingDispatcher { clientKind: options?.clientKind, clientCapabilities: options?.clientCapabilities, updateClientCapabilities: options?.updateClientCapabilities, - orchestrationCapability: request.orchestrationCapability, authenticatedCallerFingerprint: mutation?.identity.callerFingerprint ?? legacyCoordinator?.mutationCallerFingerprint ?? @@ -196,7 +195,6 @@ export class RpcStreamingDispatcher { clientKind: options?.clientKind, clientCapabilities: options?.clientCapabilities, updateClientCapabilities: options?.updateClientCapabilities, - orchestrationCapability: request.orchestrationCapability, pairing: options?.pairing, sendBinary: options?.sendBinary, registerBinaryStreamHandler: options?.registerBinaryStreamHandler, diff --git a/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts b/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts new file mode 100644 index 000000000000..4ce22b0a3600 --- /dev/null +++ b/src/main/runtime/rpc/worktree-removal-marker-projection.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, + NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, + WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY +} from '../../../shared/protocol-version' +import { remoteRuntimeClientCapabilities } from '../../../shared/remote-runtime-client-capabilities' +import type { + RuntimeWorktreeListResult, + RuntimeWorktreePsResult +} from '../../../shared/runtime-worktree-contracts' +import { + _resetPendingWorktreeRemovalsForTests, + snapshotPendingWorktreeRemovals, + startBackgroundWorktreeRemoval +} from '../../worktree-background-removal' +import { + projectWorktreeListRemovals, + projectWorktreePsRemovals +} from './worktree-removal-marker-projection' + +const removingId = 'repo-1::/work/feature' +const keptId = 'repo-1::/work/other' + +function listResult(): RuntimeWorktreeListResult { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the projection reads only id, hostId and the counts. + return { + worktrees: [ + { id: removingId, hostId: 'local' }, + { id: keptId, hostId: 'local' } + ], + totalCount: 2, + truncated: false + } as unknown as RuntimeWorktreeListResult +} + +function psResult(): RuntimeWorktreePsResult { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the projection reads only worktreeId, hostId and the counts. + return { + worktrees: [ + { worktreeId: removingId, hostId: 'local' }, + { worktreeId: keptId, hostId: 'local' } + ], + totalCount: 2, + truncated: false + } as unknown as RuntimeWorktreePsResult +} + +describe('worktree listings while a checkout is being deleted', () => { + beforeEach(() => { + void startBackgroundWorktreeRemoval({ + removal: { + worktreeId: removingId, + repoId: 'repo-1', + repoPath: '/work/repo', + worktree: { path: '/work/feature', branch: 'refs/heads/feature', head: 'abc' }, + deleteBranch: true, + force: false + }, + run: () => new Promise(() => {}), + publish: () => {} + }) + }) + + afterEach(() => { + _resetPendingWorktreeRemovalsForTests() + }) + + it('marks the row for a desktop or web client that negotiated the marker', () => { + const context = { clientCapabilities: ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } + expect( + projectWorktreeListRemovals(listResult(), context, snapshotPendingWorktreeRemovals()) + .worktrees + ).toEqual([ + { id: removingId, hostId: 'local', removing: true }, + { id: keptId, hostId: 'local' } + ]) + expect( + projectWorktreePsRemovals(psResult(), context, snapshotPendingWorktreeRemovals()).worktrees + ).toEqual([ + { worktreeId: removingId, hostId: 'local', removing: true }, + { worktreeId: keptId, hostId: 'local' } + ]) + }) + + it('leaves the row out for a client that would show it as a normal workspace', () => { + // An older desktop or web build, the CLI, the phone, and an in-process caller alike. + for (const clientCapabilities of [ + [], + undefined, + NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, + remoteRuntimeClientCapabilities() + ]) { + const list = projectWorktreeListRemovals( + listResult(), + { clientCapabilities }, + snapshotPendingWorktreeRemovals() + ) + expect(list.worktrees.map((row) => row.id)).toEqual([keptId]) + expect(list.totalCount).toBe(1) + const ps = projectWorktreePsRemovals( + psResult(), + { clientCapabilities }, + snapshotPendingWorktreeRemovals() + ) + expect(ps.worktrees.map((row) => row.worktreeId)).toEqual([keptId]) + expect(ps.totalCount).toBe(1) + } + }) + + it('advertises the marker only from the desktop renderer, never the shared remote defaults', () => { + // Mobile and the CLI send the shared defaults and have no Deleting affordance to show. + expect(ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES).toContain( + WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY + ) + expect(remoteRuntimeClientCapabilities()).not.toContain( + WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY + ) + expect(NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES).not.toContain( + WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY + ) + }) +}) diff --git a/src/main/runtime/rpc/worktree-removal-marker-projection.ts b/src/main/runtime/rpc/worktree-removal-marker-projection.ts new file mode 100644 index 000000000000..f4f13ca9c418 --- /dev/null +++ b/src/main/runtime/rpc/worktree-removal-marker-projection.ts @@ -0,0 +1,63 @@ +import { WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import type { DetectedWorktreeListResult } from '../../../shared/worktree/types' +import type { + RuntimeWorktreeListResult, + RuntimeWorktreePsResult +} from '../../../shared/runtime-worktree-contracts' +import { + projectPendingWorktreeRemovals, + type PendingWorktreeRemovals +} from '../../worktree-background-removal' +import type { RpcContext } from './core' + +// Why no in-process default: callers without negotiation (the CLI, host-side readers) print or act +// on rows, and a checkout mid-delete is not a workspace they can use. +export function readsWorktreeRemovalMarker( + context: Pick +): boolean { + return ( + context.clientCapabilities?.includes(WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY) === true + ) +} + +export function projectWorktreeListRemovals< + T extends RuntimeWorktreeListResult | DetectedWorktreeListResult +>( + result: T, + context: Pick, + pendingAtScan: PendingWorktreeRemovals +): T { + const worktrees = projectPendingWorktreeRemovals( + result.worktrees, + (worktree) => worktree.id, + readsWorktreeRemovalMarker(context), + pendingAtScan + ) + if (worktrees === result.worktrees) { + return result + } + const omitted = result.worktrees.length - worktrees.length + return 'totalCount' in result + ? { ...result, worktrees, totalCount: result.totalCount - omitted } + : { ...result, worktrees } +} + +export function projectWorktreePsRemovals( + result: RuntimeWorktreePsResult, + context: Pick, + pendingAtScan: PendingWorktreeRemovals +): RuntimeWorktreePsResult { + const worktrees = projectPendingWorktreeRemovals( + result.worktrees, + (summary) => summary.worktreeId, + readsWorktreeRemovalMarker(context), + pendingAtScan + ) + return worktrees === result.worktrees + ? result + : { + ...result, + worktrees, + totalCount: result.totalCount - (result.worktrees.length - worktrees.length) + } +} diff --git a/src/main/runtime/runtime-file-command-host.ts b/src/main/runtime/runtime-file-command-host.ts index 8f6763f09e55..3fb74c716326 100644 --- a/src/main/runtime/runtime-file-command-host.ts +++ b/src/main/runtime/runtime-file-command-host.ts @@ -6,6 +6,7 @@ import type { } from './runtime-file-command-target' import type { ExecutionHostId } from '../../shared/execution-host' import type { RuntimeNativeChatFileContext } from '../../shared/runtime-types' +import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import type { FsChangeEvent } from '../../shared/filesystem-entry-types' import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' import { @@ -52,14 +53,16 @@ export type RuntimeFileCommandHost = { worktreeId: string, filePath: string, relativePath: string, - runtimeEnvironmentId?: string | null + runtimeEnvironmentId?: string | null, + navigation?: RuntimeNavigationTarget ): void openDiff( worktreeId: string, filePath: string, relativePath: string, staged: boolean, - runtimeEnvironmentId?: string | null + runtimeEnvironmentId?: string | null, + navigation?: RuntimeNavigationTarget ): void } diff --git a/src/main/runtime/runtime-file-commands-constructor.ts b/src/main/runtime/runtime-file-commands-constructor.ts index 1af54400c427..ecd21b43994e 100644 --- a/src/main/runtime/runtime-file-commands-constructor.ts +++ b/src/main/runtime/runtime-file-commands-constructor.ts @@ -11,6 +11,7 @@ import { } from './runtime-file-command-host' import { basenameFromRelativePath } from './runtime-file-paths' import type { RuntimeFileListResult, RuntimeFileOpenResult } from '../../shared/runtime-types' +import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import { listQuickOpenFiles } from '../ipc/filesystem-list-files' import { MOBILE_FILE_LIST_LIMIT, @@ -155,7 +156,8 @@ export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithA async openMobileFile( worktreeSelector: string, - relativePath: string + relativePath: string, + navigation?: RuntimeNavigationTarget ): Promise { const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) const { worktree } = target @@ -177,7 +179,7 @@ export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithA // Why: CLI/agents treat opened:true as success; stat first so missing paths fail the RPC instead of opening a ghost tab. await this.assertMobileOpenTargetExists(filePath, runtimeFileRouteForTarget(target)) // Why: the internal runtimeId isn't a valid env selector; pass undefined so openFile falls back to activeRuntimeEnvironmentId. - this.host.openFile(worktree.id, filePath, relativePath, undefined) + this.host.openFile(worktree.id, filePath, relativePath, undefined, navigation) return { worktree: worktree.id, relativePath, kind, opened: true } } @@ -203,7 +205,8 @@ export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithA async openMobileDiff( worktreeSelector: string, relativePath: string, - staged: boolean + staged: boolean, + navigation?: RuntimeNavigationTarget ): Promise { const { worktree } = await this.host.resolveRuntimeFileTarget(worktreeSelector) if (!isSafeMobileRelativePath(relativePath)) { @@ -216,7 +219,7 @@ export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithA : 'text' const filePath = joinWorktreeRelativePath(worktree.path, relativePath) // Why: see openMobileFile; avoid stamping internal runtimeId as runtimeEnvironmentId. - this.host.openDiff(worktree.id, filePath, relativePath, staged, undefined) + this.host.openDiff(worktree.id, filePath, relativePath, staged, undefined, navigation) return { worktree: worktree.id, relativePath, kind, opened: true } } } diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts new file mode 100644 index 000000000000..8fd770875878 --- /dev/null +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts @@ -0,0 +1,361 @@ +// Real-binary coverage for finishing a removal a quit or crash interrupted: what is left has to +// come from Git and disk, whatever point the earlier run reached. +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, realpath, unlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../shared/repo-types' +import { removeTree } from '../../shared/windows-transient-lock-removal' +import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' +import type { Store } from '../persistence' +import type * as HostTreeRemoval from '../host-tree-removal' +import { removeHostTree } from '../host-tree-removal' +import type * as WorktreeGitFileRestore from '../git/worktree-git-file-restore' +import { restoreMissingWorktreeGitFile } from '../git/worktree-git-file-restore' +import { listWorktreesStrict } from '../git/worktree' +import { areWorktreePathsEqual } from '../git/worktree-path-comparison' +import { + acquireWatcherRemovalGate, + beginTerminalInstall, + beginWatcherInstall +} from '../ipc/watcher-removal-gate' +import { + _resetPendingWorktreeRemovalsForTests, + _settlePendingWorktreeRemovalsForTests, + loadWorktreeRemovalRecords, + resumeInterruptedWorktreeRemovals, + waitForPendingWorktreeRemoval +} from '../worktree-background-removal' +import { + readWorktreeRemovalRecords, + writeWorktreeRemovalRecords, + type WorktreeRemovalRecord +} from '../worktree-removal-records' +import { interruptedLocalWorktreeRemovalJob } from './runtime-interrupted-local-worktree-removal' + +vi.mock('../project-runtime-git-options', () => ({ + getLocalProjectWorktreeGitOptions: () => ({}) +})) +vi.mock('../host-tree-removal', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, removeHostTree: vi.fn(actual.removeHostTree) } +}) +vi.mock('../git/worktree-git-file-restore', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + restoreMissingWorktreeGitFile: vi.fn(actual.restoreMissingWorktreeGitFile) + } +}) + +const execFileAsync = promisify(execFile) + +let scratchDir = '' +let recordsDir = '' +let repoPath = '' +let worktreePath = '' +let repo: Repo + +async function git(args: string[], cwd = repoPath): Promise { + const { stdout } = await execFileAsync('git', args, { cwd }) + return stdout +} + +// Why parsed: Git prints forward slashes on Windows, so raw text never contains a joined path. +async function isRegistered(path: string): Promise { + return (await listWorktreesStrict(repoPath)).some((worktree) => + areWorktreePathsEqual(worktree.path, path) + ) +} + +beforeEach(async () => { + // realpath: macOS hands out /var/... temp paths while Git reports /private/var/.... + scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-interrupted-removal-'))) + recordsDir = join(scratchDir, 'profile') + repoPath = join(scratchDir, 'repo') + worktreePath = join(scratchDir, 'workspaces', 'feature') + await mkdir(recordsDir, { recursive: true }) + await mkdir(repoPath, { recursive: true }) + await git(['init', '-q']) + await git(['config', 'user.email', 'removal@example.invalid']) + await git(['config', 'user.name', 'Worktree Removal']) + await writeFile(join(repoPath, 'seed.txt'), 'seed\n') + await git(['add', '-A']) + await git(['commit', '-qm', 'seed']) + await git(['worktree', 'add', '-q', worktreePath, '-b', 'feature']) + repo = { id: 'repo-1', path: repoPath, displayName: 'repo', badgeColor: '', addedAt: 0 } +}) + +afterEach(async () => { + _resetPendingWorktreeRemovalsForTests() + vi.mocked(removeHostTree).mockClear() + await removeTree(scratchDir) +}) + +type FinishOutcome = + | ({ status: 'removed' } & RemoveWorktreeResult) + | { status: 'failed'; error: string } + +async function finishAfterRestart(options: { repoGone?: boolean; head?: string } = {}): Promise<{ + outcome: FinishOutcome + purged: string[] + remember: ReturnType +}> { + const record: WorktreeRemovalRecord = { + worktreeId: `repo-1::${worktreePath}`, + repoId: 'repo-1', + repoPath, + worktreePath, + branch: 'feature', + head: options.head ?? (await git(['rev-parse', 'feature'])).trim(), + deleteBranch: true, + force: false, + requestedAt: 1 + } + await writeWorktreeRemovalRecords(recordsDir, () => [record]) + await loadWorktreeRemovalRecords(recordsDir) + // A request that joins before the finish starts gets the finish's result. + const joined = waitForPendingWorktreeRemoval(record.worktreeId) + expect(joined).toBeDefined() + // Session restore runs before the resume: nothing may open a handle in the half-deleted checkout. + expect(() => beginTerminalInstall(worktreePath)).toThrow(/being removed/) + expect(() => beginWatcherInstall(worktreePath)).toThrow(/being removed/) + + const storeStub = { + getRepo: (id: string) => (id === repo.id && !options.repoGone ? repo : undefined), + getRepos: () => (options.repoGone ? [] : [repo]), + getWorktreeMeta: () => undefined + } + const purged: string[] = [] + const remember = vi.fn() + resumeInterruptedWorktreeRemovals((interrupted) => + interruptedLocalWorktreeRemovalJob(interrupted, { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the finish reads only repos and worktree metadata from the store here; git options and push-target cleanup are stubbed or short-circuit without a push target. + store: storeStub as unknown as Store, + // Takes the real gate synchronously, as the runtime's does. + acquireWatcherRemoval: async (path) => { + const gate = acquireWatcherRemovalGate(path) + expect(() => beginTerminalInstall(worktreePath)).toThrow(/being removed/) + return { finish: async () => gate.release() } + }, + closeWatchers: async () => {}, + preservedBranchCleanup: { + preserveHead: (result) => result ?? {}, + remember + }, + purge: ({ worktreeId }) => purged.push(worktreeId), + onRemoved: () => {}, + publish: () => {} + }) + ) + const outcome: FinishOutcome = await joined!.then( + (result) => ({ status: 'removed' as const, ...result }), + (error: unknown) => ({ status: 'failed' as const, error: String(error) }) + ) + await _settlePendingWorktreeRemovalsForTests() + expect(await readWorktreeRemovalRecords(recordsDir)).toEqual([]) + expect(waitForPendingWorktreeRemoval(record.worktreeId)).toBeUndefined() + // Released on every outcome, including a finish that ended before taking its own gate. + beginTerminalInstall(worktreePath)() + return { outcome, purged, remember } +} + +describe('finishing an interrupted worktree removal after a restart', () => { + it('finishes a checkout Git was still deleting, branch and metadata included', async () => { + // Git stopped partway: part of the checkout is gone, which reads as local changes. + await unlink(join(worktreePath, 'seed.txt')) + + const { outcome, purged } = await finishAfterRestart() + + expect(outcome).toMatchObject({ status: 'removed' }) + expect( + outcome && 'preservedBranch' in outcome ? outcome.preservedBranch : undefined + ).toBeUndefined() + expect(existsSync(worktreePath)).toBe(false) + expect(await isRegistered(worktreePath)).toBe(false) + expect(await git(['branch', '--list', 'feature'])).toBe('') + expect(purged).toEqual([`repo-1::${worktreePath}`]) + }) + + it('lets Git finish a checkout whose .git file it had already deleted', async () => { + // Git deletes in directory order; without `.git` it refuses the checkout ("validation failed"). + await unlink(join(worktreePath, '.git')) + await unlink(join(worktreePath, 'seed.txt')) + + const { outcome, purged } = await finishAfterRestart() + + expect(outcome).toMatchObject({ status: 'removed' }) + expect(existsSync(worktreePath)).toBe(false) + expect(await isRegistered(worktreePath)).toBe(false) + expect(await git(['branch', '--list', 'feature'])).toBe('') + expect(purged).toEqual([`repo-1::${worktreePath}`]) + // Git's process deleted it, not Orca's. + expect(removeHostTree).not.toHaveBeenCalled() + }) + + it('lets Git finish a relative-path checkout whose .git file it had already deleted', async (ctx) => { + await git(['worktree', 'remove', worktreePath]) + try { + await git(['worktree', 'add', '-q', '--relative-paths', worktreePath, 'feature']) + } catch { + // Git before 2.48 has no relative-path worktrees. + ctx.skip() + } + await unlink(join(worktreePath, '.git')) + await unlink(join(worktreePath, 'seed.txt')) + + const { outcome, purged } = await finishAfterRestart() + + expect(outcome).toMatchObject({ status: 'removed' }) + expect(existsSync(worktreePath)).toBe(false) + expect(await isRegistered(worktreePath)).toBe(false) + expect(purged).toEqual([`repo-1::${worktreePath}`]) + expect(removeHostTree).not.toHaveBeenCalled() + }) + + it('deletes the leftover itself only when Git cannot be pointed back at it', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await unlink(join(worktreePath, '.git')) + vi.mocked(restoreMissingWorktreeGitFile).mockResolvedValueOnce(false) + + const { outcome, purged } = await finishAfterRestart() + + expect(outcome).toMatchObject({ status: 'removed' }) + expect(removeHostTree).toHaveBeenCalledWith(worktreePath) + expect(existsSync(worktreePath)).toBe(false) + expect(await isRegistered(worktreePath)).toBe(false) + expect(await git(['branch', '--list', 'feature'])).toBe('') + expect(purged).toEqual([`repo-1::${worktreePath}`]) + }) + + it('leaves a different checkout created at the same path since the quit', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const head = (await git(['rev-parse', 'feature'])).trim() + await git(['worktree', 'remove', worktreePath]) + await git(['worktree', 'add', '-q', worktreePath, '-b', 'other']) + await writeFile(join(worktreePath, 'unsaved.txt'), 'work\n') + + const { outcome, purged } = await finishAfterRestart({ head }) + + expect(outcome).toMatchObject({ status: 'failed' }) + expect(existsSync(join(worktreePath, 'unsaved.txt'))).toBe(true) + expect(await isRegistered(worktreePath)).toBe(true) + expect(purged).toEqual([]) + }) + + it('leaves a repository created at the path after Git unregistered the checkout', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await git(['worktree', 'remove', worktreePath]) + await mkdir(worktreePath, { recursive: true }) + await git(['init', '-q'], worktreePath) + await writeFile(join(worktreePath, 'unsaved.txt'), 'work\n') + + const { outcome, purged } = await finishAfterRestart() + + expect(outcome).toMatchObject({ status: 'failed' }) + expect(existsSync(join(worktreePath, 'unsaved.txt'))).toBe(true) + expect(removeHostTree).not.toHaveBeenCalled() + expect(purged).toEqual([]) + }) + + it('leaves the same branch checked out again at a new head', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const head = (await git(['rev-parse', 'feature'])).trim() + await git(['worktree', 'remove', worktreePath]) + await git(['worktree', 'add', '-q', worktreePath, 'feature']) + await writeFile(join(worktreePath, 'work.txt'), 'work\n') + await git(['add', '-A'], worktreePath) + await git(['commit', '-qm', 'work'], worktreePath) + + const { outcome, purged } = await finishAfterRestart({ head }) + + expect(outcome).toMatchObject({ status: 'failed' }) + expect(existsSync(join(worktreePath, 'work.txt'))).toBe(true) + expect(purged).toEqual([]) + }) + + it('leaves a locked checkout alone even when its .git file is gone', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await git(['worktree', 'lock', worktreePath]) + await unlink(join(worktreePath, '.git')) + + const { outcome, purged } = await finishAfterRestart() + + expect(outcome).toMatchObject({ status: 'failed' }) + expect(existsSync(join(worktreePath, 'seed.txt'))).toBe(true) + expect(purged).toEqual([]) + }) + + it('deletes the branch when Git finished the checkout but the quit came before the branch', async () => { + await git(['worktree', 'remove', worktreePath]) + + const { outcome, purged } = await finishAfterRestart() + + expect(outcome).toMatchObject({ status: 'removed' }) + expect(await git(['branch', '--list', 'feature'])).toBe('') + expect(purged).toEqual([`repo-1::${worktreePath}`]) + }) + + it('keeps an unmerged branch, as a normal removal does', async () => { + await writeFile(join(worktreePath, 'work.txt'), 'work\n') + await git(['add', '-A'], worktreePath) + await git(['commit', '-qm', 'work'], worktreePath) + const head = (await git(['rev-parse', 'feature'])).trim() + await git(['worktree', 'remove', worktreePath]) + + const { outcome, remember } = await finishAfterRestart() + + expect(outcome).toMatchObject({ + status: 'removed', + preservedBranch: { branchName: 'feature', head } + }) + expect((await git(['rev-parse', 'feature'])).trim()).toBe(head) + expect(remember).toHaveBeenCalledWith( + `repo-1::${worktreePath}`, + undefined, + { preservedBranch: { branchName: 'feature', head } }, + head, + undefined + ) + }) + + it('treats a removal that fully finished as done', async () => { + const head = (await git(['rev-parse', 'feature'])).trim() + await git(['worktree', 'remove', worktreePath]) + await git(['branch', '-d', 'feature']) + + const { outcome, purged } = await finishAfterRestart({ head }) + + expect(outcome).toMatchObject({ status: 'removed' }) + expect( + outcome && 'preservedBranch' in outcome ? outcome.preservedBranch : undefined + ).toBeUndefined() + expect(purged).toEqual([`repo-1::${worktreePath}`]) + }) + + it('returns the row live when the finish fails, instead of retrying unseen', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + // Another Git client locked it while Orca was not running. + await git(['worktree', 'lock', worktreePath]) + + const { outcome, purged } = await finishAfterRestart() + + expect(outcome).toMatchObject({ status: 'failed' }) + expect(existsSync(worktreePath)).toBe(true) + expect(await isRegistered(worktreePath)).toBe(true) + expect(purged).toEqual([]) + }) + + it('drops a record whose repo Orca no longer has', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const { outcome, purged } = await finishAfterRestart({ repoGone: true }) + + expect(outcome).toMatchObject({ status: 'removed' }) + expect(purged).toEqual([]) + expect(existsSync(worktreePath)).toBe(true) + }) +}) diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts new file mode 100644 index 000000000000..ca50faf3b8f1 --- /dev/null +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts @@ -0,0 +1,201 @@ +import { lstat } from 'node:fs/promises' +import { join } from 'node:path' +import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' +import type { GitWorktreeInfo } from '../../shared/worktree/types' +import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { Store } from '../persistence' +import { + releaseStartupRemovalFence, + type BackgroundWorktreeRemovalJob +} from '../worktree-background-removal' +import { resolveWorktreeRemovalMetadata } from '../worktree-removal-repo-owner' +import type { RuntimePreservedBranchCleanup } from './runtime-preserved-branch-cleanup' +import { listWorktreesStrict } from '../git/worktree' +import { finishUnregisteredWorktreeRemoval } from '../git/worktree-removal' +import { getErrorCode, normalizeLocalBranchRef } from '../git/worktree-operation-options' +import { restoreMissingWorktreeGitFile } from '../git/worktree-git-file-restore' +import { areWorktreePathsEqual } from '../git/worktree-path-comparison' +import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' +import { findRegisteredDeletableWorktree } from '../worktree-removal-safety' +import { CLIENT_REMOVAL_HOME } from '../worktree-removal-home-guard' +import type { WorktreeRemovalRecord } from '../worktree-removal-records' +import { + cleanupRemovedWorktreePushTarget, + finishRuntimeLocalWorktreeRemoval, + type RuntimeLocalWorktreeRemovalFinishArgs +} from './runtime-registered-local-worktree-removal' + +type InterruptedWorktreeRemovalHost = { + store: Store + acquireWatcherRemoval: (path: string) => Promise<{ finish: (removed: boolean) => Promise }> + closeWatchers: (path: string) => Promise + preservedBranchCleanup: Pick + /** Drops the worktree's host state (metadata, history, caches), as every removal path does. */ + purge: (record: WorktreeRemovalRecord) => void + onRemoved: (record: WorktreeRemovalRecord) => void + publish: (repoId: string) => void +} + +/** The background job that finishes one interrupted removal on this host. */ +export function interruptedLocalWorktreeRemovalJob( + record: WorktreeRemovalRecord, + host: InterruptedWorktreeRemovalHost +): BackgroundWorktreeRemovalJob { + return { + run: async (stopSignal) => { + const removedPushTarget = resolveWorktreeRemovalMetadata( + host.store, + record.repoId, + record.worktreeId, + LOCAL_EXECUTION_HOST_ID + )?.pushTarget + const result = await finishInterruptedLocalWorktreeRemoval({ + record, + store: host.store, + stopSignal, + removedPushTarget, + acquireWatcherRemoval: (path) => { + // Why same tick: the gate takes over the loading fence's path with no gap for a spawn. + releaseStartupRemovalFence(record.worktreeId) + return host.acquireWatcherRemoval(path) + }, + closeWatchers: host.closeWatchers, + preserveBranchHead: (result, fallbackHead) => + host.preservedBranchCleanup.preserveHead(result, fallbackHead), + // remember() clears the cleanup target when no branch was preserved. + finishRemoval: (result, _rememberBranch, fallbackHead) => { + host.preservedBranchCleanup.remember( + record.worktreeId, + undefined, + result, + fallbackHead, + removedPushTarget + ) + host.purge(record) + } + }) + host.onRemoved(record) + return result + }, + publish: () => host.publish(record.repoId) + } +} + +type InterruptedLocalWorktreeRemovalArgs = Pick< + RuntimeLocalWorktreeRemovalFinishArgs, + 'removedPushTarget' | 'closeWatchers' | 'preserveBranchHead' | 'finishRemoval' +> & { + store: Store + record: WorktreeRemovalRecord + acquireWatcherRemoval: (path: string) => Promise<{ finish: (removed: boolean) => Promise }> + stopSignal: AbortSignal +} + +/** + * Finishes a removal a quit or crash interrupted. What is left comes from Git and disk, not the + * record, so a delete Git already finished (fully or partly) completes the same way. + */ +async function finishInterruptedLocalWorktreeRemoval( + args: InterruptedLocalWorktreeRemovalArgs +): Promise { + const { record, store } = args + const repo = store.getRepo(record.repoId) + if (!repo) { + console.warn(`[worktrees] dropping removal of ${record.worktreePath}: its repo is gone`) + return {} + } + const localOptions = getLocalProjectWorktreeGitOptions(store, repo) + const finishArgs: RuntimeLocalWorktreeRemovalFinishArgs = { + store, + removedPushTarget: args.removedPushTarget, + closeWatchers: args.closeWatchers, + preserveBranchHead: args.preserveBranchHead, + finishRemoval: args.finishRemoval, + repo, + localOptions, + // Why force: Git already deleted part of the checkout, which reads as local changes. + force: true, + deleteBranch: record.deleteBranch, + target: { id: record.worktreeId } + } + const worktrees = await listWorktreesStrict(repo.path, localOptions) + const registered = worktrees.some((worktree) => + areWorktreePathsEqual(worktree.path, record.worktreePath) + ) + const deletable = registered + ? findRegisteredDeletableWorktree( + repo.path, + record.worktreePath, + worktrees, + CLIENT_REMOVAL_HOME + ) + : undefined + if (registered && !deletable) { + throw new Error( + `Worktree registration changed during deletion: ${record.worktreePath}. Retry deletion.` + ) + } + const gitLink = await readCheckoutGitLink(record.worktreePath) + // Why: the finish forces, so a checkout created at this path since the quit must not be taken. + // Git deletes the checkout, `.git` included, before it drops the registration, so a `.git` at an + // unregistered path belongs to a new checkout. + if (deletable ? !isRecordedCheckout(deletable, record) : gitLink === 'present') { + throw new Error( + `A different checkout is now at ${record.worktreePath}; Orca left it in place. Delete it again to remove it.` + ) + } + // Why: Git deletes `.git` wherever it falls in directory order (early on NTFS) and refuses to + // remove a checkout left without it; restoring the link from Git's admin entry lets Git finish. + let gitCanRemove = !!deletable + if (deletable && gitLink === 'missing') { + assertWorktreeUnlockedForRemoval(deletable) + gitCanRemove = await restoreMissingWorktreeGitFile(repo.path, deletable.path, localOptions) + } + const gate = await args.acquireWatcherRemoval(record.worktreePath) + if (deletable && gitCanRemove) { + return finishRuntimeLocalWorktreeRemoval(finishArgs, deletable, gate, args.stopSignal) + } + // Unregistered, or no admin entry claims the checkout so Git cannot validate it: the leftover is + // deleted in this process as a last resort, then pruned. + let result: RemoveWorktreeResult + let removed = false + try { + result = await finishUnregisteredWorktreeRemoval( + repo.path, + record.worktreePath, + record.deleteBranch && record.branch ? { name: record.branch, head: record.head } : null, + localOptions + ) + removed = true + } finally { + await gate.finish(removed) + } + await cleanupRemovedWorktreePushTarget(finishArgs) + args.finishRemoval(result, true, record.head) + return result +} + +function isRecordedCheckout(worktree: GitWorktreeInfo, record: WorktreeRemovalRecord): boolean { + return ( + normalizeLocalBranchRef(worktree.branch) === record.branch && + (!record.head || worktree.head === record.head) + ) +} + +/** `missing`: the checkout directory is there without its `.git`; unreadable counts as present. */ +async function readCheckoutGitLink( + worktreePath: string +): Promise<'no-checkout' | 'missing' | 'present'> { + try { + await lstat(worktreePath) + } catch { + return 'no-checkout' + } + try { + await lstat(join(worktreePath, '.git')) + return 'present' + } catch (error) { + return getErrorCode(error) === 'ENOENT' ? 'missing' : 'present' + } +} diff --git a/src/main/runtime/runtime-local-worktree-create-candidate.ts b/src/main/runtime/runtime-local-worktree-create-candidate.ts index aacf6ae0b495..7af0f8b99104 100644 --- a/src/main/runtime/runtime-local-worktree-create-candidate.ts +++ b/src/main/runtime/runtime-local-worktree-create-candidate.ts @@ -32,6 +32,7 @@ import { resolveCreateBranchName } from './runtime-worktree-create-git' import { runtimePathExists } from './runtime-worktree-filesystem' +import { findPendingWorktreeRemovalConflict } from '../worktree-background-removal' import type { RuntimeStore } from './runtime-store-contract' import type { HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options' @@ -189,7 +190,11 @@ export async function resolveRuntimeLocalWorktreeCreateCandidate(args: { computeWorktreePath(effectiveSanitizedName, args.repo.path, args.worktreePathSettings), args.workspaceRoot ) - if (!(await runtimePathExists(worktreePath))) { + // Why the pending check: Orca still owns this path until its background removal settles. + if ( + !findPendingWorktreeRemovalConflict(args.repo.path, { worktreePath }) && + !(await runtimePathExists(worktreePath)) + ) { worktreePathResolved = true break } diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index fa87d839cd5c..c5b279e41db2 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -2,6 +2,7 @@ import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resum import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry' import type { TerminalRevealIdentity } from '../../shared/terminal-reveal-identity' import type { TuiAgent } from '../../shared/tui-agent' +import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows' import type { WorktreeBaseStatusEvent, @@ -97,18 +98,24 @@ export type RuntimeNotifier = { focusEditorTab?(tabId: string, worktreeId: string): void closeSessionTab?(tabId: string, worktreeId: string): void | Promise moveSessionTab?(worktreeId: string, move: RuntimeMobileSessionTabMove): void + /** + * Acts only on the host's own window: 'host'/'all' move it, 'caller'/'clients' open without moving + * it, absent keeps the original switch. Paired clients are never navigated (intended; 'all' == 'host'). + */ openFile?( worktreeId: string, filePath: string, relativePath: string, - runtimeEnvironmentId?: string | null + runtimeEnvironmentId?: string | null, + navigation?: RuntimeNavigationTarget ): void openDiff?( worktreeId: string, filePath: string, relativePath: string, staged: boolean, - runtimeEnvironmentId?: string | null + runtimeEnvironmentId?: string | null, + navigation?: RuntimeNavigationTarget ): void readMobileMarkdownTab?(worktreeId: string, tabId: string): Promise saveMobileMarkdownTab?( diff --git a/src/main/runtime/runtime-registered-local-worktree-removal.ts b/src/main/runtime/runtime-registered-local-worktree-removal.ts index 011d30207fe2..a5e966b1471a 100644 --- a/src/main/runtime/runtime-registered-local-worktree-removal.ts +++ b/src/main/runtime/runtime-registered-local-worktree-removal.ts @@ -26,8 +26,28 @@ import { findRegisteredDeletableWorktree } from '../worktree-removal-safety' import { CLIENT_REMOVAL_HOME } from '../worktree-removal-home-guard' import type { RuntimeStore } from './runtime-store-contract' import type { RuntimeWorktreeRemovalTarget } from './runtime-worktree-selection' +import { + removesInBackground, + startBackgroundWorktreeRemoval, + waitForPendingWorktreeRemoval +} from '../worktree-background-removal' +import { runSerializedWorktreeRemovalAcceptance } from '../worktree-removal-acceptance-queue' + +/** Runs after the previous same-repo removal was accepted; see runSerializedWorktreeRemovalAcceptance. */ +export function removeRuntimeRegisteredLocalWorktree( + args: Parameters[0] +): Promise { + return runSerializedWorktreeRemovalAcceptance( + args.repo.path, + async (): Promise => + // Why: another client's removal of this worktree may have been accepted during the wait. + waitForPendingWorktreeRemoval(args.target.id) + ? { removing: true } + : acceptRuntimeRegisteredLocalWorktreeRemoval(args) + ) +} -export async function removeRuntimeRegisteredLocalWorktree(args: { +async function acceptRuntimeRegisteredLocalWorktreeRemoval(args: { repo: Repo target: RuntimeWorktreeRemovalTarget registeredWorktree: GitWorktreeInfo @@ -54,6 +74,9 @@ export async function removeRuntimeRegisteredLocalWorktree(args: { // Why: re-read after the archive hook, which can move the branch out from under the pre-hook row. fallbackHead: string | undefined ) => void + /** Fired when Git finished, not on acceptance. */ + onRemoved: () => void + publish: () => void }): Promise { const { repo, registeredWorktree, localOptions } = args const canonicalPath = registeredWorktree.path @@ -121,20 +144,81 @@ export async function removeRuntimeRegisteredLocalWorktree(args: { } } - let removalResult: RemoveWorktreeResult | undefined const gate = await args.acquireWatcherRemoval(canonicalPath) - let completed = false + let accepted = false try { await args.stopPtys() if (linkedPaths.length > 0) { await removeWorktreeLinkedPaths(canonicalPath, linkedPaths) } + accepted = true + } finally { + if (!accepted) { + await gate.finish(false) + } + } + const acceptedFields = { + ...(archiveHookOverride ? { archiveHookOverride } : {}), + ...(warning ? { warning } : {}) + } + if (!removesInBackground(canonicalPath, localOptions)) { + const result = await finishRuntimeLocalWorktreeRemoval(args, refreshed, gate) + args.publish() + return { ...result, ...acceptedFields } + } + // Why detached: every refusal above already ran, and Git's 20-35 s delete must finish even when the + // request that asked for it times out; other views read the host's `removing` marker meanwhile. + void startBackgroundWorktreeRemoval({ + removal: { + worktreeId: args.target.id, + repoId: repo.id, + repoPath: repo.path, + worktree: refreshed, + deleteBranch: args.deleteBranch, + force: args.force + }, + run: async (stopSignal) => { + const result = await finishRuntimeLocalWorktreeRemoval(args, refreshed, gate, stopSignal) + args.onRemoved() + return result + }, + publish: () => args.publish() + }) + return { removing: true, ...acceptedFields } +} + +export type RuntimeLocalWorktreeRemovalFinishArgs = Pick< + Parameters[0], + | 'repo' + | 'removedPushTarget' + | 'store' + | 'localOptions' + | 'force' + | 'deleteBranch' + | 'closeWatchers' + | 'preserveBranchHead' + | 'finishRemoval' +> & { target: { id: string } } + +/** Git's delete and everything after it; the refusals and teardown before it already ran. */ +export async function finishRuntimeLocalWorktreeRemoval( + args: RuntimeLocalWorktreeRemovalFinishArgs, + refreshed: GitWorktreeInfo, + gate: { finish: (removed: boolean) => Promise }, + checkoutDeleteSignal?: AbortSignal +): Promise { + const { repo, localOptions } = args + const canonicalPath = refreshed.path + let removalResult: RemoveWorktreeResult | undefined + let completed = false + try { try { removalResult = args.preserveBranchHead( await removeWorktree(repo.path, canonicalPath, args.force, { ...(!args.deleteBranch ? { deleteBranch: args.deleteBranch } : {}), knownRemovedWorktree: refreshed, - ...localOptions + ...localOptions, + ...(checkoutDeleteSignal ? { checkoutDeleteSignal } : {}) }), refreshed.head ) @@ -162,13 +246,10 @@ export async function removeRuntimeRegisteredLocalWorktree(args: { await gitExecFileAsync(['worktree', 'prune'], { cwd: repo.path, ...localOptions }).catch( () => {} ) - await cleanupPushTarget(args) + await cleanupRemovedWorktreePushTarget(args) args.finishRemoval(undefined, false, refreshed.head) completed = true - return { - ...(archiveHookOverride ? { archiveHookOverride } : {}), - ...(warning ? { warning } : {}) - } + return {} } else { throw new Error(formatWorktreeRemovalError(error, canonicalPath, args.force)) } @@ -179,17 +260,13 @@ export async function removeRuntimeRegisteredLocalWorktree(args: { } finally { await gate.finish(completed) } - await cleanupPushTarget(args) + await cleanupRemovedWorktreePushTarget(args) args.finishRemoval(removalResult, true, refreshed.head) - return { - ...removalResult, - ...(archiveHookOverride ? { archiveHookOverride } : {}), - ...(warning ? { warning } : {}) - } + return removalResult ?? {} } -async function cleanupPushTarget( - args: Parameters[0] +export async function cleanupRemovedWorktreePushTarget( + args: RuntimeLocalWorktreeRemovalFinishArgs ): Promise { await cleanupUnusedWorktreePushTargetRemote( args.repo.path, diff --git a/src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts b/src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts index 88f4aefb1861..1d033036d533 100644 --- a/src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts +++ b/src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts @@ -635,7 +635,7 @@ describe('OrcaRuntimeRpcServer', () => { expect(mocks.abortRuntimeGitMerge).toHaveBeenCalledWith('id:wt-1') expect(mocks.abortRuntimeGitRebase).toHaveBeenCalledWith('id:wt-1') expect(mocks.bulkUnstageRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['c.ts']) - expect(mocks.openMobileDiff).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md', true) + expect(mocks.openMobileDiff).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md', true, undefined) // A mobile WebSocket client is transport-capped; a local caller gets undefined here. expect(mocks.getRuntimeGitDiff).toHaveBeenCalledWith( 'id:wt-1', diff --git a/src/main/runtime/runtime-server-git-availability.test.ts b/src/main/runtime/runtime-server-git-availability.test.ts index 2259e1a563ac..77bcfbbfa0b4 100644 --- a/src/main/runtime/runtime-server-git-availability.test.ts +++ b/src/main/runtime/runtime-server-git-availability.test.ts @@ -11,10 +11,6 @@ vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) import { RuntimeServerEnvironmentCommands } from './runtime-server-environment-commands' -function spawnEnoent(): Error { - return Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT', syscall: 'spawn git' }) -} - describe('RuntimeServerEnvironmentCommands.isGitAvailable', () => { const commands = new RuntimeServerEnvironmentCommands() @@ -30,27 +26,4 @@ describe('RuntimeServerEnvironmentCommands.isGitAvailable', () => { gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git version 2.25.1\n', stderr: '' }) await expect(commands.isGitAvailable()).resolves.toBe(true) }) - - it('answers false only when the spawn itself found no binary', async () => { - gitExecFileAsyncMock.mockRejectedValue(spawnEnoent()) - await expect(commands.isGitAvailable()).resolves.toBe(false) - }) - - it('rejects an ENOENT when the working directory disappeared', async () => { - vi.spyOn(process, 'cwd').mockReturnValue(`${process.cwd()}-missing`) - gitExecFileAsyncMock.mockRejectedValue(spawnEnoent()) - await expect(commands.isGitAvailable()).rejects.toThrow('spawn git ENOENT') - }) - - it('rejects a slow host rather than reporting no Git', async () => { - gitExecFileAsyncMock.mockRejectedValue(new Error('git --version timed out after 3000ms')) - await expect(commands.isGitAvailable()).rejects.toThrow('timed out') - }) - - it('rejects a repository-level git failure rather than reporting no Git', async () => { - gitExecFileAsyncMock.mockRejectedValue( - Object.assign(new Error('detected dubious ownership'), { code: 128 }) - ) - await expect(commands.isGitAvailable()).rejects.toThrow('dubious ownership') - }) }) diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index e08fffa273cf..d623a3f1fd14 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -132,6 +132,9 @@ export type RuntimeTerminalAgentStatusEvent = { * reader can rejoin it to the terminal after the pane key moved. */ terminalHandle?: string payload: ParsedAgentStatusPayload + /** Set by the process-lifetime producer; see AgentHookServer.ingestTerminalStatus. */ + origin?: 'process' + yieldsToHookSince?: number } export type HookLiveAgentRow = Pick< diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index 32ebf1a423b7..d3fd9a0d7b34 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -112,6 +112,8 @@ export type RuntimePtyTitleTrackerEntry = { lastTitleFactAtMs: number | null chunkTouchedSessionTabs: boolean pendingFacts: TerminalSideEffectFact[] + /** Run once this chunk's facts are emitted: status that readers must see after them. */ + afterFacts: (() => void)[] commandCodeDetector: { observe: (data: string) => boolean } | null } @@ -120,6 +122,8 @@ export type RuntimeHeadlessTerminal = { outputSequence: number writeChain: Promise ownership: PtyShellOwnershipMirror + /** The grid a reattach reflowed the model onto, until a PTY resize off it repaints the TUI. */ + unrepaintedReflowGrid?: { cols: number; rows: number } } export type RuntimeVisibleTerminalState = { diff --git a/src/main/runtime/runtime-terminal-wait.ts b/src/main/runtime/runtime-terminal-wait.ts index 240f55085812..64c46ff2dcee 100644 --- a/src/main/runtime/runtime-terminal-wait.ts +++ b/src/main/runtime/runtime-terminal-wait.ts @@ -2,7 +2,6 @@ import type { RuntimeTerminalWait as RuntimeTerminalWaitResult, RuntimeTerminalWaitCondition } from '../../shared/runtime-types' -import { hasAntigravityTerminalHeader } from './antigravity-terminal-readiness' import { buildPtyTerminalWaitBlockedResult, buildPtyTerminalWaitResult, @@ -10,6 +9,8 @@ import { buildTerminalWaitResult, getTerminalState } from './terminal-wait-results' +import { getScreenReadyRule } from './screen-ruled-agent-readiness' +import { hasAntigravityTerminalHeader } from './antigravity-terminal-readiness' import { buildTerminalWaitText } from './terminal-wait-tail-state' import { evaluateTuiIdle, @@ -24,6 +25,24 @@ import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-term import type { RuntimeTerminalIdlePolls } from './runtime-terminal-idle-polls' import type { RuntimeTerminalWaiterRegistry } from './runtime-terminal-waiter-registry' +/** + * A pane with no retained bytes and no status has only its provider's screen to read, and one + * whose tail shows the Antigravity banner is probed as before screen rules. So is a clockless + * screen-ruled pane whatever its status: a re-attached pane's own model can be untrusted. A + * clocked one settles through the poll, once quiet. + */ +function shouldProbeVisibleScreen( + paneAgent: TuiAgent | null, + record: Pick, + waitText: string +): boolean { + return ( + (record.lastAgentStatus === null && waitText.length === 0) || + hasAntigravityTerminalHeader(waitText) || + (getScreenReadyRule(paneAgent) !== null && record.lastOutputAt === null) + ) +} + type RuntimeTerminalWaitDependencies = TuiIdleEvidenceSource & { defaultTimeoutMs: number getLivePty(handle: string): { pty: RuntimePtyWorktreeRecord } | null @@ -130,18 +149,7 @@ export class RuntimeTerminalWait { } else { this.polls.startPty(waiter, live.pty, verdict) const paneAgent = this.deps.getPaneAgent(live.pty.ptyId) - if ( - // AGY can retain a stale working/blocked status after a trust dialog was - // dismissed. Its visible composer is authoritative, so probe whenever the - // pane is identified as AGY (or its banner is present), regardless of that - // stale status. - (paneAgent === 'antigravity' || - hasAntigravityTerminalHeader(livePtyWaitText) || - live.pty.lastAgentStatus === null) && - (livePtyWaitText.length === 0 || - paneAgent === 'antigravity' || - hasAntigravityTerminalHeader(livePtyWaitText)) - ) { + if (shouldProbeVisibleScreen(paneAgent, live.pty, livePtyWaitText)) { this.deps.startVisibleReadProbe(waiter, effectiveTimeoutMs, paneAgent) } } @@ -232,14 +240,7 @@ export class RuntimeTerminalWait { // preview/title until the waiter resolves or hits its timeout. this.polls.startLeaf(waiter, live.leaf, verdict) const paneAgent = this.deps.getPaneAgent(live.leaf.ptyId) - if ( - (paneAgent === 'antigravity' || - hasAntigravityTerminalHeader(liveLeafWaitText) || - live.leaf.lastAgentStatus === null) && - (liveLeafWaitText.length === 0 || - paneAgent === 'antigravity' || - hasAntigravityTerminalHeader(liveLeafWaitText)) - ) { + if (shouldProbeVisibleScreen(paneAgent, live.leaf, liveLeafWaitText)) { this.deps.startVisibleReadProbe(waiter, effectiveTimeoutMs, paneAgent) } } diff --git a/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts b/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts index 01c726198309..9116bbb6605d 100644 --- a/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts +++ b/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts @@ -91,12 +91,6 @@ describe('worktree ps reports structured sessions', () => { expect(row.status).toBe('working') }) - // The same projection the sidebar applies, so the two surfaces cannot disagree about one session. - it('maps attention to blocked and idle to done', () => { - expect(attach([summary({ status: 'attention' })]).agents[0]?.state).toBe('blocked') - expect(attach([summary({ status: 'idle' })]).agents[0]?.state).toBe('done') - }) - it('does not turn a completed host-held session into permission', () => { const row = attach([summary({ status: 'idle' })]) expect(row.status).toBe('inactive') diff --git a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts index 875f77a754b5..0d9180dc3e57 100644 --- a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts +++ b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentSessionFailureFact } from '../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import type { @@ -89,9 +90,14 @@ function publishedSummary(journal: AgentSessionJournal): AgentSessionStatusSumma return summary } -function ingest(summary: AgentSessionStatusSummary) { +/** `children` is child-work evidence the host's records admit under the row before it re-folds. */ +function ingest(summary: AgentSessionStatusSummary, children: AgentChildWorkEvidence[] = []) { const store = new AgentHookServer() store.ingestStructuredStatus(summary, SUBJECT) + if (children.length > 0) { + store.ingestStructuredChildWork(SUBJECT, children, 'codex') + store.ingestStructuredStatus({ ...summary, updatedAt: summary.updatedAt + 1 }, SUBJECT) + } const hookSnapshots = store.getStatusSnapshot() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: attaching agent rows reads and writes only `worktreeId`, `status`, `hasHostSidebarActivity` and `agents`. const row = { @@ -183,12 +189,20 @@ describe('a request that failed reads as failed through the feed, the ingest and { kind: 'turn', turnId: 'turn-1', state: 'completed', outcome: 'failure', completedAt: 5 }, { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) - const summary: AgentSessionStatusSummary = { - ...publishedSummary(journal), - backgroundTasks: [{ id: 'child-1', kind: 'agent', state: 'working' }] - } - - const { status, ps } = ingest(summary) + // The row folds the host's live child records, never the summary's task list. + const { status, ps } = ingest(publishedSummary(journal), [ + { + type: 'live', + observedAt: 1_000, + child: { + handle: { idKind: 'task_id', id: 'child-1' }, + kind: 'agent', + residency: 'background', + state: 'working', + stoppable: true + } + } + ]) expect(status).toMatchObject({ state: 'working', mainAgent: { state: 'done', outcome: 'failure' } diff --git a/src/main/runtime/runtime-worktree-selection.ts b/src/main/runtime/runtime-worktree-selection.ts index 230f2952da84..2a00d01ced15 100644 --- a/src/main/runtime/runtime-worktree-selection.ts +++ b/src/main/runtime/runtime-worktree-selection.ts @@ -39,6 +39,8 @@ export type RemoveManagedWorktreeOptions = { /** Waives a FAILED archive hook (#19334). Never implied by `force`, never by `runHooks`. */ allowFailedArchiveHook?: boolean hostId?: string + /** Reply with the finished delete's result; otherwise a background delete replies on acceptance. */ + waitForBackgroundRemoval?: boolean } export function getRuntimeWorktreeRemovalOptionsKey( diff --git a/src/main/runtime/screen-ruled-agent-readiness.ts b/src/main/runtime/screen-ruled-agent-readiness.ts new file mode 100644 index 000000000000..b5a4257243d0 --- /dev/null +++ b/src/main/runtime/screen-ruled-agent-readiness.ts @@ -0,0 +1,36 @@ +import type { TuiAgent } from '../../shared/tui-agent' +import { isAntigravityComposerReadyScreen } from './antigravity-terminal-readiness' +import { isClineComposerReadyScreen } from './cline-terminal-readiness' +import { isPrimeAgentComposerReadyScreen } from './prime-agent-terminal-readiness' + +type ScreenReadyRule = (screenLines: readonly string[]) => boolean + +/** + * Agents whose live screen decides readiness. Each rule reads an idle composer off the grid, + * which a folded text tail loses to cursor addressing. Why a clocked pane waits for quiet too: + * the captures paint that composer for a moment mid-turn (a submit repaint, a spinner row + * erased before its redraw) and, for Prime, just before its first-launch question. + */ +const SCREEN_READY_RULES: Partial> = { + antigravity: isAntigravityComposerReadyScreen, + cline: isClineComposerReadyScreen, + 'prime-agent': isPrimeAgentComposerReadyScreen +} + +export function getScreenReadyRule(agent: TuiAgent | null | undefined): ScreenReadyRule | null { + return agent ? (SCREEN_READY_RULES[agent] ?? null) : null +} + +/** + * The agent's screen rule applied to its live screen, or null when it has no rule or no + * trustworthy screen is readable. Why a verdict outranks every text rule: the screen is what + * the folded text was copied from, and the text cannot see a picker or dialog covering it. + */ +export function readScreenRuledVerdict( + agent: TuiAgent | null | undefined, + readScreenLines: () => readonly string[] | null +): boolean | null { + const rule = getScreenReadyRule(agent) + const screenLines = rule ? readScreenLines() : null + return rule && screenLines ? rule(screenLines) : null +} diff --git a/src/main/runtime/screen-ruled-agent-transcript-suite.ts b/src/main/runtime/screen-ruled-agent-transcript-suite.ts new file mode 100644 index 000000000000..b8ffca8b0cbc --- /dev/null +++ b/src/main/runtime/screen-ruled-agent-transcript-suite.ts @@ -0,0 +1,223 @@ +// One replay suite for every agent whose readiness its live screen decides (terminal-wait-detection.ts). +import { describe, expect, it, vi } from 'vitest' +import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' +import { + finalReadProjection, + finalReplayFrame, + readRuntimeFixture, + type TranscriptReplayResize +} from './agent-transcript-replay-test-harness' +import { isKnownReadyPromptBody, isQuietReadyScreenBody } from './terminal-wait-detection' +import type { TuiAgent } from '../../shared/tui-agent' + +export type ScreenRuledFixture = { name: string; cols: number; rows: number; what: string } + +export type ScreenRuledAgentSuite = { + agent: TuiAgent + foregroundProcess: string + rule: (screenLines: readonly string[]) => boolean + ready: readonly ScreenRuledFixture[] + notReady: readonly ScreenRuledFixture[] + /** Ready recordings the text rules or the quiet-process lane settle with no screen. */ + readyWithoutScreen: readonly string[] +} + +// Why these: grids out of step with the recording garble cursor-addressed chrome (#23475 review). +const MISMATCHED_GRIDS = [ + [80, 24], + [100, 30], + [60, 20], + [150, 50] +] as const + +// Why 8s: quiescence (3s) plus the 2s poll re-reading the grid. Why 5s: past both, so a refusal +// has been through the quiet lane and at least one poll tick. +const READY_TIMEOUT_MS = 8_000 +const REFUSAL_TIMEOUT_MS = 5_000 +// Why the slack: onPtyData takes seconds over a multi-MB animated recording. +const PANE_SETUP_SLACK_MS = 30_000 + +function chunkCount(name: string): number { + return Math.ceil(readRuntimeFixture(name).length / 64) +} + +export function describeScreenRuledAgentTranscripts(suite: ScreenRuledAgentSuite): void { + const { agent, rule } = suite + const [firstReady] = suite.ready + if (!firstReady) { + throw new Error(`${agent}: a suite needs a ready recording`) + } + + describe('the screen rule on the final recorded frame', () => { + it.each(suite.ready)('$name ($what) reads ready', async ({ name, cols, rows }) => { + const { ruledScreenLines } = await finalReplayFrame(name, cols, rows) + expect(rule(ruledScreenLines)).toBe(true) + }) + it.each(suite.notReady)('$name ($what) does not', async ({ name, cols, rows }) => { + const { ruledScreenLines } = await finalReplayFrame(name, cols, rows) + expect(rule(ruledScreenLines)).toBe(false) + }) + }) + + describe('fails safe on a garbled grid', () => { + it.each(suite.notReady)('$name never reads ready', async ({ name, cols, rows }) => { + const resize: TranscriptReplayResize = { + atChunk: Math.floor(chunkCount(name) / 2), + cols: 100, + rows: 30 + } + const frames = [ + ...(await Promise.all(MISMATCHED_GRIDS.map(([c, r]) => finalReplayFrame(name, c, r)))), + await finalReplayFrame(name, cols, rows, resize) + ] + for (const { ruledScreenLines, waitText } of frames) { + expect(isQuietReadyScreenBody(waitText, agent, () => ruledScreenLines)).toBe(false) + } + }) + }) + + describe('through the runtime', () => { + async function pane(fixture: ScreenRuledFixture) { + const created = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: suite.foregroundProcess, + launchAgent: agent, + data: readRuntimeFixture(fixture.name), + size: { cols: fixture.cols, rows: fixture.rows } + }) + // Why: a screen read awaits the emulator's queue. One multi-MB chunk leaves it parsing + // after the output clock has gone quiet, which a live PTY's 3s of silence never does. + await created.runtime.readTerminal(created.handle, { screen: true }) + return created + } + + it.each(suite.ready)( + '$name: a tui-idle wait settles once the pane is quiet', + async (fixture) => { + const { runtime, handle } = await pane(fixture) + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: READY_TIMEOUT_MS }) + ).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true }) + }, + READY_TIMEOUT_MS + PANE_SETUP_SLACK_MS + ) + + it.each(suite.notReady)( + '$name: a tui-idle wait does not settle ready', + async (fixture) => { + const { runtime, handle } = await pane(fixture) + const result = await runtime + .waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: REFUSAL_TIMEOUT_MS }) + .catch((error: unknown) => ({ satisfied: false, error: String(error) })) + expect(result.satisfied).toBe(false) + }, + REFUSAL_TIMEOUT_MS + PANE_SETUP_SLACK_MS + ) + + // Why 100x30: a grid no recording was painted for. The PTY reports its real size. + it.each(suite.ready)( + '$name: on a grid the PTY does not have, the lanes the screen would shut decide', + async (fixture) => { + const options = { + paneTitle: 'Terminal', + foregroundProcess: suite.foregroundProcess, + launchAgent: agent, + data: readRuntimeFixture(fixture.name), + size: { cols: 100, rows: 30 } + } + const { runtime, handle } = await createTranscriptPane(options) + await runtime.readTerminal(handle, { screen: true }) + options.size = { cols: fixture.cols, rows: fixture.rows } + const result = await runtime + .waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: READY_TIMEOUT_MS }) + .catch(() => ({ satisfied: false })) + expect(result.satisfied).toBe(suite.readyWithoutScreen.includes(fixture.name)) + }, + READY_TIMEOUT_MS + PANE_SETUP_SLACK_MS + ) + + // Why: a re-attach reflows a grid built before the real size was known; the TUI never repaints. + it( + 'on a grid reflowed to the PTY size without a repaint, the lanes the screen would shut decide', + async () => { + const options = { + paneTitle: 'Terminal', + foregroundProcess: suite.foregroundProcess, + launchAgent: agent, + data: readRuntimeFixture(firstReady.name), + size: { cols: 100, rows: 30 } + } + const { runtime, handle } = await createTranscriptPane(options) + options.size = { cols: firstReady.cols, rows: firstReady.rows } + runtime.reflowHeadlessTerminalToPtyGrid( + TRANSCRIPT_PANE_PTY_ID, + firstReady.cols, + firstReady.rows + ) + // Why: an echo of the reflowed size sends no SIGWINCH, so nothing repaints. + runtime.onExternalPtyResize(TRANSCRIPT_PANE_PTY_ID, firstReady.cols, firstReady.rows) + await runtime.readTerminal(handle, { screen: true }) + const result = await runtime + .waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: READY_TIMEOUT_MS }) + .catch(() => ({ satisfied: false })) + expect(result.satisfied).toBe(suite.readyWithoutScreen.includes(firstReady.name)) + }, + READY_TIMEOUT_MS + PANE_SETUP_SLACK_MS + ) + + // Why no bytes: a restored pane has no output clock, so the provider's screen is all it has. + // Why the read projection: it is what the probe's screen read returns, draft blanked. + async function probedVerdict(fixture: ScreenRuledFixture): Promise { + const { lines, draft } = await finalReadProjection(fixture.name, fixture.cols, fixture.rows) + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: suite.foregroundProcess, + launchAgent: agent, + data: '' + }) + const read = vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ + handle, + status: 'running', + tail: lines, + ...(draft === undefined ? {} : { draft }), + truncated: false, + nextCursor: null, + source: 'screen' + }) + const result = await runtime + .waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_500 }) + .catch(() => ({ satisfied: false })) + // Presence precondition: the visible-screen probe actually ran. + expect(read).toHaveBeenCalled() + return result.satisfied === true + } + + it.each(suite.ready)( + '$name: a restored pane settles from a visible-screen read', + async (fixture) => { + expect(await probedVerdict(fixture)).toBe(true) + }, + 15_000 + ) + + it.each(suite.notReady)( + '$name: a restored pane does not settle from a visible-screen read', + async (fixture) => { + expect(await probedVerdict(fixture)).toBe(false) + }, + 15_000 + ) + }) + + it('never reads the screen for another agent', async () => { + const { ruledScreenLines, waitText } = await finalReplayFrame( + firstReady.name, + firstReady.cols, + firstReady.rows + ) + const readScreenLines = vi.fn(() => ruledScreenLines) + isKnownReadyPromptBody(waitText, 'claude', readScreenLines, false) + expect(isQuietReadyScreenBody(waitText, 'claude', readScreenLines)).toBe(false) + expect(readScreenLines).not.toHaveBeenCalled() + }) +} diff --git a/src/main/runtime/screen-ruled-readiness-scope.test.ts b/src/main/runtime/screen-ruled-readiness-scope.test.ts new file mode 100644 index 000000000000..ea48459a4ba6 --- /dev/null +++ b/src/main/runtime/screen-ruled-readiness-scope.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from 'vitest' +import { createTranscriptPane } from './agent-transcript-pane-test-harness' +import { readRuntimeFixture } from './agent-transcript-replay-test-harness' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +// Why: the grid-trust check and raw rows belong to screen-ruled agents; every other agent must +// keep reading the screen exactly as it did before them. +describe('screen-rule trust stays with screen-ruled agents', () => { + it('a Codex pane still settles from its screen when the PTY reports another grid', async () => { + const options = { + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex' as const, + // Only the live screen shows this recording's header (STA-8628): the text reads `dirctory:`. + data: readRuntimeFixture('codex-0157-effort-override-embedded-warning'), + size: { cols: 120, rows: 40 } + } + const { runtime, handle } = await createTranscriptPane(options) + await runtime.readTerminal(handle, { screen: true }) + options.size = { cols: 100, rows: 30 } + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 8_000 }) + ).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true }) + }, 20_000) +}) diff --git a/src/main/runtime/structured-agent-session-close.ts b/src/main/runtime/structured-agent-session-close.ts index 668b7f218903..3ee8f265c4df 100644 --- a/src/main/runtime/structured-agent-session-close.ts +++ b/src/main/runtime/structured-agent-session-close.ts @@ -75,7 +75,8 @@ export async function closeStructuredAgentSessionChild( try { await host.setSessionTabVisibility?.(sessionId, false) closeAttempted = true - await host.close(sessionId) + // A worktree teardown or an orchestration stop: not the user closing this chat. + await host.close(sessionId, 'evict') } catch (error) { // Only `closeAttempted` proves the hide landed: the store transaction restores its own state on // failure, so a `setSessionTabVisibility` that threw hid nothing and has nothing to undo. diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 990c33850efe..facd5e15b05b 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -1,8 +1,9 @@ // A Codex send the turn it went into never took: the Stop that interrupts that turn -// withdraws it, and nothing reads as working after. A Stop sent before Codex opens that -// turn waits for it to open, since Codex refuses an interrupt until then. Driven through the shipped host, -// journal and Codex adapter; only the Codex child is fake, keeping Codex 0.157's -// turn bookkeeping. +// withdraws it, and nothing reads as working after. A send made while that turn runs, a +// queued card's Send-now included, goes in as `turn/steer` naming it. A Stop or a send made +// before Codex opens that turn waits for it to open, since Codex refuses an interrupt until then. +// Driven through the shipped host, journal and Codex adapter; only the Codex child is fake, +// keeping Codex 0.157's turn bookkeeping. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -15,7 +16,8 @@ import type { } from '../codex/codex-app-server-connection' import { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' import { settledWithin } from '../codex/codex-structured-dispatch-test-support' -import { CODEX_STOP_TURN_OPEN_WAIT_MS } from '../codex/codex-structured-prompt-ownership' +import { CODEX_TURN_OPEN_WAIT_MS } from '../codex/codex-structured-turn-open-wait' +import type * as CodexTurnOpenWait from '../codex/codex-structured-turn-open-wait' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types' import { classifyDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection' @@ -33,6 +35,28 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +// The turns a send or Stop is waiting on to open, so a test knows the wait began. +const openWaits = vi.hoisted(() => { + const turnIds: string[] = [] + return { turnIds } +}) +vi.mock('../codex/codex-structured-turn-open-wait', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createCodexTurnOpenWaits: () => { + const waits = actual.createCodexTurnOpenWaits() + return { + ...waits, + wait: (turnId: string, withinMs: number) => { + openWaits.turnIds.push(turnId) + return waits.wait(turnId, withinMs) + } + } + } + } +}) + const CALLER = { callerKey: 'codex-turn-end-test' } const MODEL = { model: 'gpt-test', @@ -48,6 +72,7 @@ let host: StructuredAgentSessionHost let fence: number let handlers: CodexAppServerConnectionHandlers | undefined let answers: number +let steers: number let interrupts: number let childCloses: number let turns: ReturnType @@ -78,6 +103,30 @@ async function send(text: string): Promise { return sent.value.clientMessageId } +/** A mid-turn send held as a card, then that card's Send-now; resolves with the send it made. */ +async function queueThenSendNow(text: string): Promise<{ messageId: string; sent: string }> { + const body = hostTestMessage(text) + const delivery = 'queue-if-active' as const + const queued = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body, delivery }), + body, + delivery, + userSend: true + }) + if (!queued.ok || !('queued' in queued.value)) { + throw new Error(`expected a queued card: ${JSON.stringify(queued)}`) + } + const { messageId } = queued.value.queued + const sentNow = await host.queuedMessageSend(CALLER, { + envelope: envelope('agentSession.queuedMessageSend', { messageId }), + messageId + }) + if (!sentNow.ok) { + throw new Error(JSON.stringify(sentNow.refusal)) + } + return { messageId, sent: sentNow.value.clientMessageId } +} + async function stop(turnId?: string): Promise { const stopped = await host.cancel(CALLER, { envelope: envelope('agentSession.cancel', turnId === undefined ? {} : { turnId }), @@ -88,6 +137,15 @@ async function stop(turnId?: string): Promise { } } +/** `/compact` as the chat surface runs it; refused while the chat still owes work. */ +async function compact() { + const command = 'compact' as const + return host.conversationCommand(CALLER, { + command, + envelope: envelope('agentSession.conversationCommand', { command }) + }) +} + async function settled(): Promise<{ submissions: readonly AgentJournalSubmission[] owesWork: boolean @@ -110,7 +168,9 @@ function verdictOf(submissions: readonly AgentJournalSubmission[], clientMessage beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-codex-turn-end-')) + openWaits.turnIds.length = 0 answers = 0 + steers = 0 interrupts = 0 childCloses = 0 turns = codexTurnLifecycleFake( @@ -136,6 +196,10 @@ beforeEach(async () => { answers += 1 return turns.routes['turn/start']() } + if (method === 'turn/steer') { + steers += 1 + return turns.routes['turn/steer'](params) + } if (method === 'turn/interrupt') { interrupts += 1 return turns.routes['turn/interrupt'](params) @@ -207,8 +271,9 @@ describe('a Codex send its turn ended without taking it', () => { turns.start() turns.echo(opening) const followUp = await send('and check the tests') - // Steered: Codex answers with the running turn, and fires no second turn/started. - await vi.waitFor(() => expect(answers).toBe(2)) + // Steered into the running turn by name, which fires no second turn/started. + await vi.waitFor(() => expect(steers).toBe(1)) + expect(answers).toBe(1) await stop('turn-1') @@ -222,6 +287,108 @@ describe('a Codex send its turn ended without taking it', () => { }) }) +describe('a Codex before 0.148, which names a steered start falsely', () => { + it('withdraws a send made while a turn runs when a Stop ends it, and then takes /compact', async () => { + turns = codexTurnLifecycleFake( + THREAD, + () => (method, params) => handlers?.onNotification?.(method, params), + { legacyStartAnswers: true } + ) + const opening = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(opening) + const followUp = await send('and check the tests') + await vi.waitFor(() => expect(steers + answers).toBe(2)) + + await stop() + + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, followUp)).not.toBe('pending') + ) + const after = await settled() + expect(verdictOf(after.submissions, followUp)).toBe('withdrawn') + expect(after.owesWork).toBe(false) + expect(await compact()).toMatchObject({ ok: true }) + expect(steers).toBe(1) + }) +}) + +describe("a queued card's Send-now while a Codex turn runs", () => { + async function runningTurn(): Promise { + const opening = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(opening) + } + + it('goes into that turn as turn/steer, and the turn settles it when Codex echoes it', async () => { + await runningTurn() + + const { messageId, sent } = await queueThenSendNow('and check the tests') + + await vi.waitFor(() => expect(steers).toBe(1)) + expect(answers).toBe(1) + const handedOver = (await settled()).submissions.find((entry) => entry.clientMessageId === sent) + expect(handedOver).toMatchObject({ queuedMessageId: messageId, dispatchState: 'pending' }) + turns.echo(sent) + turns.end('completed') + + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, sent)).toBe('accepted') + ) + expect((await settled()).owesWork).toBe(false) + }) + + it('is withdrawn when a Stop ends that turn, on a Codex that names a steered start falsely', async () => { + // Before 0.148, a steered turn/start answers with an id no turn opens or ends under, + // so only the steer's own answer names the turn whose end settles the send. + turns = codexTurnLifecycleFake( + THREAD, + () => (method, params) => handlers?.onNotification?.(method, params), + { legacyStartAnswers: true } + ) + await runningTurn() + + const { sent } = await queueThenSendNow('and check the tests') + await vi.waitFor(() => expect(steers + answers).toBe(2)) + + await stop('turn-1') + + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') + ) + expect(steers).toBe(1) + }) +}) + +describe('a second send made after Codex answered the first, before it opened that turn', () => { + it('joins that turn once it opens, on a Codex that names a steered start falsely, so a Stop withdraws both', async () => { + turns = codexTurnLifecycleFake( + THREAD, + () => (method, params) => handlers?.onNotification?.(method, params), + { legacyStartAnswers: true } + ) + const opening = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + const followUp = await send('and check the tests') + await vi.waitFor(() => expect(openWaits.turnIds).toEqual(['turn-1'])) + turns.start() + await vi.waitFor(() => expect(steers).toBe(1)) + expect(answers).toBe(1) + + await stop() + + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, followUp)).not.toBe('pending') + ) + const after = await settled() + expect(verdictOf(after.submissions, opening)).toBe('withdrawn') + expect(verdictOf(after.submissions, followUp)).toBe('withdrawn') + expect(after.owesWork).toBe(false) + }) +}) + describe('a Stop sent after Codex answered a cold send, before it opened the turn', () => { it('waits for the turn to open, then stops it and withdraws the send', async () => { const sent = await send('look around') @@ -271,10 +438,10 @@ describe('a Stop in that window that the turn never opens for', () => { it('lets a chat closed behind it close within its bound and one eviction', async () => { const { stopping } = await waitingStop() - const closing = host.close(SESSION) + const closing = host.close(SESSION, 'evict') expect( - await settledWithin(closing, CODEX_STOP_TURN_OPEN_WAIT_MS + CHILD_EVICTION_TIMEOUT_MS) + await settledWithin(closing, CODEX_TURN_OPEN_WAIT_MS + CHILD_EVICTION_TIMEOUT_MS) ).not.toBe('held') expect(await settledWithin(stopping, 0)).not.toBe('held') expect(childCloses).toBe(1) @@ -305,7 +472,7 @@ describe('a cold send with no Stop behind it', () => { it('never delays closing the chat', async () => { await answeredColdSend() - expect(await settledWithin(host.close(SESSION), PROMPTLY_MS)).not.toBe('held') + expect(await settledWithin(host.close(SESSION, 'evict'), PROMPTLY_MS)).not.toBe('held') expect(childCloses).toBe(1) }) diff --git a/src/main/runtime/structured-agent-session-host-refusal.test.ts b/src/main/runtime/structured-agent-session-host-refusal.test.ts index 689ac3df969c..d05c9a78422a 100644 --- a/src/main/runtime/structured-agent-session-host-refusal.test.ts +++ b/src/main/runtime/structured-agent-session-host-refusal.test.ts @@ -122,7 +122,7 @@ describe('a process whose journal will not open', () => { const logged = String(warn.mock.calls[0]?.[1]) expect(logged).toContain(path) expect(logged).toContain('unreleased development build') - expect(logged).toContain('move the file aside') + expect(logged).toContain('Moving the file aside') expect(await digest(path)).toBe(before) }) @@ -353,19 +353,19 @@ describe('startup and other non-chat work without a structured host', () => { ).resolves.toBeUndefined() await expect( assertLegacyAiVaultResumeCommandAllowed('claude --resume 0f9c1d2e', async () => { - throw new Error('the record store would not open') + throw new Error('the host would not construct') }) - ).rejects.toThrow('the record store would not open') + ).rejects.toThrow('the host would not construct') expect(gateRefusal().reason).toBe('journalCorrupt') }) it('still fails on an install error that refuses nothing', async () => { const { runtime, refreshPtyRecords } = startupRuntime(async () => { - throw new Error('the record store would not open') + throw new Error('the host would not construct') }) await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).rejects.toThrow( - 'the record store would not open' + 'the host would not construct' ) expect(refreshPtyRecords).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/structured-agent-session-install-failure.test.ts b/src/main/runtime/structured-agent-session-install-failure.test.ts index 53720659ec41..4b00cdebda60 100644 --- a/src/main/runtime/structured-agent-session-install-failure.test.ts +++ b/src/main/runtime/structured-agent-session-install-failure.test.ts @@ -57,12 +57,13 @@ describe('an install that fails after opening the chat journal', () => { mocks.failWiring.mockReturnValueOnce(true) await expect(install()).rejects.toThrow('model catalog wiring failed') - const failed = open.mock.results[0]?.value + const failed = await open.mock.results[0]?.value expect(failed).toBeInstanceOf(JournalHostDatabase) expect(failed.isClosed).toBe(true) await expect(install()).resolves.toBeDefined() expect(open).toHaveBeenCalledTimes(2) - expect(open.mock.results[1]?.value.isClosed).toBe(false) + const reopened = await open.mock.results[1]?.value + expect(reopened?.isClosed).toBe(false) }) }) diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index efc2f786c99e..cf41fdb1dfe0 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -457,6 +457,8 @@ describe('a structured codex session over agentSession.*', () => { CODEX_HOME: '/home/dev/.codex' }) const store = await readPersistedTestAgentSessionStoreText(root) + // The record this create wrote, so the checks below read the runtime's own rows. + expect(store).toContain('/home/dev/.codex') expect(store).not.toContain('EXAMPLE_GATEWAY_TOKEN') expect(store).not.toContain('"launchEnv"') const stream = await subscribe('sub-first-send') diff --git a/src/main/runtime/structured-agent-session-journal-open.ts b/src/main/runtime/structured-agent-session-journal-open.ts index 6972a18a7960..6dd9d964d48d 100644 --- a/src/main/runtime/structured-agent-session-journal-open.ts +++ b/src/main/runtime/structured-agent-session-journal-open.ts @@ -1,7 +1,13 @@ -// Opening the chat journal database for the host install. +// Opening the chat journal database for the host install. The open that migrates it to version 4 +// first reads the records file it replaces, so the copy runs in the migration's transaction. import { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import { journalOpenRefusalError } from '../native-chat/agent-session-journal/journal-open-failure' +import { + legacyAgentSessionRecordImport, + readLegacyAgentSessionRecords, + type LegacyAgentSessionRecordImportReport +} from './agent-session-legacy-record-import' import { recordStructuredAgentSessionHostInstallRefusal } from './structured-agent-session-host-refusal' // Every chat request retries a failed open, so each distinct failure is logged once, with its stack. @@ -21,11 +27,19 @@ function logOpenFailureOnce(error: unknown): void { /** The journal database. A refusal is recorded for the gate and thrown to the caller; the next * install tries again. */ -export function openStructuredAgentSessionJournalDatabase( +export async function openStructuredAgentSessionJournalDatabase(args: { stateDirectory: string -): JournalHostDatabase { + hostId: string + onLegacyRecordImportReport: (report: LegacyAgentSessionRecordImportReport) => void +}): Promise { try { - const opened = JournalHostDatabase.open(stateDirectory) + const opened = await JournalHostDatabase.open(args.stateDirectory, async () => + legacyAgentSessionRecordImport( + await readLegacyAgentSessionRecords(args.stateDirectory, args.hostId), + args.hostId, + args.onLegacyRecordImportReport + ) + ) recordStructuredAgentSessionHostInstallRefusal(null) lastLoggedOpenFailure = null return opened diff --git a/src/main/runtime/structured-agent-session-record-import-install.test.ts b/src/main/runtime/structured-agent-session-record-import-install.test.ts new file mode 100644 index 000000000000..551fab477c41 --- /dev/null +++ b/src/main/runtime/structured-agent-session-record-import-install.test.ts @@ -0,0 +1,171 @@ +// The records file's one-time copy never keeps the host from installing. A file no read will make +// usable is reported and left alone; a read that can clear leaves the copy owed, and until a later +// launch makes it, the chat list says it cannot tell rather than "none". + +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import Database from '../sqlite/sync-database' +import { journalPragmaNumber } from '../native-chat/agent-session-journal/journal-database' +import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { legacyAgentSessionStorePath } from './agent-session-record-store-file' +import { OrcaRuntimeService } from './orca-runtime' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +const NOW = 1_800_000_000_000 +const IMPORTED = 'session-alpha-1' +const CREATED_WHILE_OWED = 'session-new-0001' + +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-record-import-install-')) +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime().catch(() => undefined) + vi.restoreAllMocks() + await chmod(legacyAgentSessionStorePath(root), 0o600).catch(() => undefined) + await rm(root, { recursive: true, force: true }) +}) + +async function writeLegacy(contents: string): Promise { + await mkdir(dirname(legacyAgentSessionStorePath(root)), { recursive: true }) + await writeFile(legacyAgentSessionStorePath(root), contents) +} + +const legacyFile = (): string => + JSON.stringify({ + schemaVersion: 2, + hostId: 'local', + records: { [IMPORTED]: agentSessionRecordFixture() }, + operations: {}, + retiredClaimKeys: [], + unusableRecords: {} + }) + +function databaseVersion(): number { + const db = new Database(journalDatabasePath(root)) + try { + return journalPragmaNumber(db, 'user_version') + } finally { + db.close() + } +} + +/** The runtime at startup, rooted at `root`, with its PTY daemon stubbed. */ +function startupRuntime(onError: (input: { scope: string; error: unknown }) => void) { + const runtime = new OrcaRuntimeService() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are the runtime's own protected members; the test roots the host at `root` and stubs the PTY daemon. + const internal = runtime as unknown as { + hasPersistedStructuredAgentSessionStore(): boolean + ensureStructuredAgentSessionHost(): Promise + refreshMobileSessionPtyRecords(): Promise | null> + structuredAgentSessionInventoryUnverifiable: boolean + } + internal.hasPersistedStructuredAgentSessionStore = () => true + internal.ensureStructuredAgentSessionHost = () => + ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveEnvironment: async () => ({}), + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + onError + }) + internal.refreshMobileSessionPtyRecords = async () => new Set() + return { + runtime, + host: () => internal.ensureStructuredAgentSessionHost(), + unverifiable: () => internal.structuredAgentSessionInventoryUnverifiable + } +} + +function importReports(onError: ReturnType): unknown[] { + return onError.mock.calls + .map(([input]) => input) + .filter((input) => input.scope === 'structured-agent-session-record-import') + .map((input) => input.error) +} + +// A read that can clear: the file's own permissions, as a locked-down or mid-restore profile has. +it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'installs while the file cannot be read, says it cannot tell, and copies it once it can', + async () => { + await writeLegacy(legacyFile()) + await chmod(legacyAgentSessionStorePath(root), 0o000) + const onError = vi.fn() + const first = startupRuntime(onError) + + await first.runtime.restoreStructuredAgentSessionTabs() + const host = await first.host() + + expect(importReports(onError)).toEqual([ + { + kind: 'unavailable', + error: expect.objectContaining({ message: 'agent_session_store_corrupt' }) + } + ]) + expect(host.legacyRecordImportOwed()).toBe(true) + expect(first.unverifiable()).toBe(true) + expect(databaseVersion()).toBe(3) + // A chat started meanwhile lives in the database and keeps its row through the copy. + await host.deps.store.reserveOwner({ + sessionId: CREATED_WHILE_OWED, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + expectedFence: null, + spawnToken: 'spawn-new', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'client-1', + operationId: `${NOW}-${'0'.repeat(31)}1`, + fingerprint: 'fp-new' + }, + now: NOW + }) + await stopStructuredAgentSessionRuntime() + + await chmod(legacyAgentSessionStorePath(root), 0o600) + const second = startupRuntime(vi.fn()) + await second.runtime.restoreStructuredAgentSessionTabs() + const relaunched = await second.host() + + expect(relaunched.legacyRecordImportOwed()).toBe(false) + expect(second.unverifiable()).toBe(false) + expect(databaseVersion()).toBe(4) + expect(relaunched.deps.store.getRecord(IMPORTED)).not.toBeNull() + expect(relaunched.deps.store.getRecord(CREATED_WHILE_OWED)).not.toBeNull() + } +) + +it('installs over a file no read will make usable, reports it once, and leaves it untouched', async () => { + await writeLegacy('{ truncated') + const onError = vi.fn() + const { runtime, host } = startupRuntime(onError) + + await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() + + expect(importReports(onError)).toEqual([ + { kind: 'unusable', error: expect.objectContaining({ message: 'agent_session_store_corrupt' }) } + ]) + expect((await host()).legacyRecordImportOwed()).toBe(false) + expect(databaseVersion()).toBe(4) + await stopStructuredAgentSessionRuntime() + expect(await readFile(legacyAgentSessionStorePath(root), 'utf-8')).toBe('{ truncated') +}) diff --git a/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts b/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts index c5aa604cb65e..0b65c556db85 100644 --- a/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts +++ b/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts @@ -7,8 +7,8 @@ import { safeParseWorkspaceSession } from '../../shared/workspace-session-schema import { journalDirectoryFor } from '../native-chat/agent-session-journal/journal-paths' import { openTestAgentSessionRecordStore, - seedTestAgentSessionRecordStore, - testAgentSessionStoreFilePath + readPersistedTestAgentSessionStore, + seedTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration' @@ -123,9 +123,9 @@ describe('structured session rollback compatibility', () => { await expect(readFile(join(journalDir, 'journal.log'), 'utf8')).resolves.toBe( 'durable-journal-fixture\n' ) - expect(JSON.parse(await readFile(testAgentSessionStoreFilePath(root), 'utf8'))).toMatchObject({ - visibleSessionIds: [SESSION] - }) + expect((await readPersistedTestAgentSessionStore(root)).sessionTabs).toEqual([ + { tabId: expect.any(String), sessionId: SESSION } + ]) await reloaded.setSessionTabVisibility(SESSION, false) const afterClose = await openTestAgentSessionRecordStore(root) diff --git a/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts b/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts index 91bf91738b5b..9bce63de54da 100644 --- a/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts @@ -50,7 +50,7 @@ describe('a Claude child proving its start', () => { expect(claude.child(STALLED).calls).toEqual(['get_settings']) let closed = false - void host.close(STALLED).then(() => { + void host.close(STALLED, 'evict').then(() => { closed = true }) await vi.waitFor(() => expect(closed).toBe(true)) diff --git a/src/main/runtime/structured-agent-session-runtime.test.ts b/src/main/runtime/structured-agent-session-runtime.test.ts index 3e2a1f14b869..e1d37c452a1e 100644 --- a/src/main/runtime/structured-agent-session-runtime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime.test.ts @@ -1,6 +1,6 @@ -import { mkdtemp, rm } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionClaimStatus, @@ -10,6 +10,15 @@ import type { } from '../../shared/agent-session-record' import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table' import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { + NO_LEGACY_JOURNAL_RECORDS, + type JournalLegacyRecordImport +} from '../native-chat/agent-session-journal/journal-database' +import { + JournalHostDatabase, + journalDatabasePath +} from '../native-chat/agent-session-journal/journal-host-database' +import { legacyAgentSessionStorePath } from './agent-session-record-store-file' import { createStructuredAgentSessionOwnerProbe, createStructuredAgentSessionOwnerProbes @@ -54,35 +63,79 @@ const OWNER: AgentSessionProcessIdentity = { const deadProbe = () => vi.fn(async () => ({ outcome: 'pid-absent' }) as const) describe('structured agent-session store presence', () => { - it('stops after finding the durable primary store', () => { - const fileExists = vi.fn(() => true) + let profile: string + afterEach(async () => { + if (profile) { + await rm(profile, { recursive: true, force: true }) + profile = '' + } + }) - expect(hasPersistedStructuredAgentSessionStore('/profile', fileExists)).toBe(true) - expect(fileExists).toHaveBeenCalledOnce() - expect(fileExists).toHaveBeenCalledWith( - join('/profile', 'agent-sessions', 'agent-sessions.json') - ) + async function openProfileDatabase( + legacyRecords: JournalLegacyRecordImport = NO_LEGACY_JOURNAL_RECORDS + ): Promise { + profile = await mkdtemp(join(tmpdir(), 'orca-session-presence-')) + return JournalHostDatabase.openWith(profile, legacyRecords) + } + + async function writeRecordsFile(): Promise { + const filePath = legacyAgentSessionStorePath(profile) + await mkdir(dirname(filePath), { recursive: true }) + await writeFile(filePath, '{}') + } + + // Every host install creates the database, chats or not; startup restore must not wait on one. + it('reports a profile whose database holds no chat absent', async () => { + ;(await openProfileDatabase()).close() + + expect(hasPersistedStructuredAgentSessionStore(profile)).toBe(false) }) - it('checks the durable backup when the primary store is absent', () => { + it('reports a chat record in the database', async () => { + const database = await openProfileDatabase() + database.db + .prepare('INSERT INTO agent_session_records (session_id, record_json) VALUES (?, ?)') + .run('session-1', '{}') + database.close() + + expect(hasPersistedStructuredAgentSessionStore(profile)).toBe(true) + }) + + it('lets the records file answer while the database still owes its copy', async () => { + ;(await openProfileDatabase({ owed: true })).close() + expect(hasPersistedStructuredAgentSessionStore(profile)).toBe(false) + + await writeRecordsFile() + expect(hasPersistedStructuredAgentSessionStore(profile)).toBe(true) + }) + + it('reports a database it cannot read present', async () => { + profile = await mkdtemp(join(tmpdir(), 'orca-session-presence-')) + await writeFile(journalDatabasePath(profile), 'not a database') + + expect(hasPersistedStructuredAgentSessionStore(profile)).toBe(true) + }) + + // A profile from before the records moved into the database still holds a chat to import. + it('checks the records file and its backup when the database is absent', () => { const fileExists = vi.fn((path: string) => path.endsWith('.bak')) expect(hasPersistedStructuredAgentSessionStore('/profile', fileExists)).toBe(true) expect(fileExists).toHaveBeenNthCalledWith( - 1, + 2, join('/profile', 'agent-sessions', 'agent-sessions.json') ) expect(fileExists).toHaveBeenNthCalledWith( - 2, + 3, join('/profile', 'agent-sessions', 'agent-sessions.json.bak') ) }) - it('reports a fresh profile absent after two bounded presence checks', () => { + it('reports a fresh profile absent after three bounded presence checks', () => { const fileExists = vi.fn(() => false) expect(hasPersistedStructuredAgentSessionStore('/profile', fileExists)).toBe(false) - expect(fileExists).toHaveBeenCalledTimes(2) + expect(fileExists).toHaveBeenCalledTimes(3) }) }) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 82c9803e2a76..aecf034b3708 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -12,7 +12,6 @@ import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' import { existsSync } from 'node:fs' -import { join } from 'node:path' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { DISPATCH_DOUBT_PROVIDER_IDLE } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' import type { AgentSessionResumeTrigger } from '../../shared/agent-session-resume-marker' @@ -42,10 +41,9 @@ import { import { AgentSessionRecordStore } from './agent-session-record-store' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' -import { - AGENT_SESSION_STORE_DIR_NAME, - agentSessionStorePath -} from './agent-session-record-store-file' +import { legacyAgentSessionStorePath } from './agent-session-record-store-file' +import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' +import { journalDatabaseHoldsAgentSessions } from '../native-chat/agent-session-journal/journal-database' import { createStructuredAgentSessionOwnerProbe, createStructuredAgentSessionOwnerProbes @@ -61,11 +59,25 @@ import { type RuntimeAgentAccountHomeResolver } from './structured-agent-model-catalog-wiring' +/** Whether this profile holds a structured chat: a record or tab in the journal database, or the + * records file a profile from before it carries while the database still owes its copy. */ export function hasPersistedStructuredAgentSessionStore( stateDirectory: string, fileExists: (path: string) => boolean = existsSync ): boolean { - const filePath = agentSessionStorePath(join(stateDirectory, AGENT_SESSION_STORE_DIR_NAME)) + const databasePath = journalDatabasePath(stateDirectory) + if (fileExists(databasePath)) { + try { + const holds = journalDatabaseHoldsAgentSessions(databasePath) + if (holds !== undefined) { + return holds + } + } catch { + // A database that cannot be read cannot say it is empty. + return true + } + } + const filePath = legacyAgentSessionStorePath(stateDirectory) return fileExists(filePath) || fileExists(`${filePath}.bak`) } @@ -194,7 +206,14 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise + deps.onError + ? deps.onError({ scope: 'structured-agent-session-record-import', error: report }) + : console.warn('[structured-agent-session] importing the chat records file', report) + }) try { return await installOnJournal(deps, journalDatabase) } catch (error) { @@ -210,10 +229,7 @@ async function installOnJournal( ): Promise { const envResolvers = createStructuredAgentEnvironmentResolvers(deps) const { resolveCodexEnvironment, resolveClaudeInheritedEnv } = envResolvers - const store = await AgentSessionRecordStore.open({ - directory: join(deps.stateDirectory, AGENT_SESSION_STORE_DIR_NAME), - hostId: deps.hostId - }) + const store = AgentSessionRecordStore.open({ journalDatabase, hostId: deps.hostId }) let host: StructuredAgentSessionHost | null = null const lifecycle = createStructuredAgentSessionLifecycleDelivery({ handle: (event) => host?.handleAdapterEvent(event), @@ -232,7 +248,7 @@ async function installOnJournal( }) ) } - // The provider going idle is what re-derives a doubted send: it can no longer be holding it. + // The provider going idle is what re-derives a doubted send; a late echo still accepts it. const releaseUnansweredDispatches = ({ sessionId }: { sessionId: string }): void => { void host ?.releaseUnansweredDispatches({ @@ -259,8 +275,6 @@ async function installOnJournal( ...(deps.openCodexConnection ? { openConnection: deps.openCodexConnection } : {}), ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), modelCatalog: agentModelCatalogStore, - onBackgroundTasksChanged: (sessionId, state) => - host?.publishBackgroundTaskState(sessionId, state), onChildWorkEvidence: (sessionId, evidence) => host?.publishChildWorkEvidence(sessionId, evidence), onDispatchSettledLate, @@ -288,8 +302,6 @@ async function installOnJournal( } : {}), onLifecycleEvent: (event) => lifecycle.deliver(event), - onBackgroundTasksChanged: (sessionId, state) => - host?.publishBackgroundTaskState(sessionId, state), onChildWorkEvidence: (sessionId, evidence) => host?.publishChildWorkEvidence(sessionId, evidence), onDispatchSettledLate, @@ -317,6 +329,10 @@ async function installOnJournal( : {}), onEventSinkError: ({ sessionId, error }) => deps.onError?.({ scope: `structured-agent-session-journal:${sessionId}`, error }), + onLeaseReconcileFailure: (error) => + deps.onError + ? deps.onError({ scope: 'structured-agent-session-lease-reconcile', error }) + : console.warn('[structured-agent-session] reconciling chat leases failed', error), ...(deps.onSessionStatusChanged ? { onSessionStatusChanged: deps.onSessionStatusChanged } : {}), ...(deps.statusSink ? { statusSink: deps.statusSink } : {}), ...(deps.hasOpenDispatch ? { hasOpenDispatch: deps.hasOpenDispatch } : {}), diff --git a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts index 3cf61c0bf3c9..d86c54282c10 100644 --- a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts +++ b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts @@ -142,6 +142,8 @@ describe('a process that inherited a spawn token', () => { resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveEnvironment: async () => ({}) }) + // The installed host reads the record the seed wrote, so the check below has a lease to act on. + expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') await host.reconcileRestartLeases() // Install work that is not awaited would have run by now; nothing here waits on a timer. await new Promise((resolve) => setTimeout(resolve, 50)) diff --git a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts new file mode 100644 index 000000000000..32b30a5dc779 --- /dev/null +++ b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts @@ -0,0 +1,163 @@ +// A profile whose chat records a newer Orca wrote opens read-only. The startup reconcile adjudicates +// its leases in memory and writes nothing there; a reconcile whose write fails is bookkeeping too. +// Either way startup must finish rather than put the whole app into its degraded "Session restore +// failed" mode. + +import { readFile, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import type * as AgentSessionRecordRows from './agent-session-record-rows' +import { + seedTestAgentSessionRecordStore, + seedTestAgentSessionStoreFromNewerBuild +} from './agent-session-record-store-test-harness' +import { closeTestJournalHostDatabases } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { OrcaRuntimeService } from './orca-runtime' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +const writes = vi.hoisted(() => ({ failing: false })) + +vi.mock('./agent-session-record-rows', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeAgentSessionStoreRows: (...args: Parameters) => { + if (writes.failing) { + throw new Error('disk I/O error') + } + return actual.writeAgentSessionStoreRows(...args) + } + } +}) + +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-startup-reconcile-')) +}) + +afterEach(async () => { + writes.failing = false + await stopStructuredAgentSessionRuntime().catch(() => undefined) + closeTestJournalHostDatabases() + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +/** A profile with one chat whose last owner is gone; `newer` stamps it as a newer Orca's. */ +async function seedChat(options: { newer?: boolean } = {}) { + const record = agentSessionRecordFixture() + await seedTestAgentSessionRecordStore(root, { records: [record] }) + if (options.newer) { + await seedTestAgentSessionStoreFromNewerBuild(root) + } + closeTestJournalHostDatabases() + return { path: journalDatabasePath(root), sessionId: record.sessionId } +} + +function startupRuntime(onError?: (input: { scope: string; error: unknown }) => void) { + const runtime = new OrcaRuntimeService() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are the runtime's own protected members; the test roots the host at `root` and stubs the PTY daemon. + const internal = runtime as unknown as { + hasPersistedStructuredAgentSessionStore(): boolean + ensureStructuredAgentSessionHost(): Promise + refreshMobileSessionPtyRecords(): Promise | null> + } + internal.hasPersistedStructuredAgentSessionStore = () => true + internal.ensureStructuredAgentSessionHost = () => + ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveEnvironment: async () => ({}), + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + ...(onError ? { onError } : {}) + }) + internal.refreshMobileSessionPtyRecords = async () => new Set() + return runtime +} + +it('finishes startup over records a newer Orca wrote, reads them, and writes nothing', async () => { + const { path, sessionId } = await seedChat({ newer: true }) + const bytes = await readFile(path) + const onError = vi.fn() + const runtime = startupRuntime(onError) + + // What the renderer's startup awaits through `app:prepareTerminalStartupRestoration`. + await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() + + expect(onError).not.toHaveBeenCalled() + const host = getStructuredAgentSessionHost() + expect(host?.sessionAgent(sessionId)).toBe('claude') + // Adjudicated in memory only: the verdict is re-derived at the next start. + expect(host?.deps.store.getRecord(sessionId)?.lease.unreconciled).toBe(false) + await stopStructuredAgentSessionRuntime() + expect(await readFile(path)).toEqual(bytes) +}) + +it('finishes startup when the reconcile cannot write, and reports it', async () => { + const { sessionId } = await seedChat() + const onError = vi.fn() + const runtime = startupRuntime(onError) + writes.failing = true + + await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() + + expect(onError).toHaveBeenCalledOnce() + expect(onError).toHaveBeenCalledWith({ + scope: 'structured-agent-session-lease-reconcile', + error: expect.objectContaining({ message: 'disk I/O error' }) + }) + expect(getStructuredAgentSessionHost()?.sessionAgent(sessionId)).toBe('claude') +}) + +// The desktop installs its host with no error sink, so the failure is logged rather than dropped. +it('logs the failure when the host has no error sink', async () => { + await seedChat() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + writes.failing = true + + await expect( + startupRuntime().prepareStructuredAgentSessionStartupRestoration() + ).resolves.toBeUndefined() + + expect(warn).toHaveBeenCalledWith( + '[structured-agent-session] reconciling chat leases failed', + expect.objectContaining({ message: 'disk I/O error' }) + ) +}) + +// Closing a chat tab drops it from the restore index, which a newer Orca's records refuse: the +// close is reported and goes on, since bookkeeping never keeps a tab open. +it('closes a chat tab over records a newer Orca wrote, reporting the index it cannot write', async () => { + const { sessionId } = await seedChat({ newer: true }) + const runtime = startupRuntime(vi.fn()) + await runtime.prepareStructuredAgentSessionStartupRestoration() + const host = getStructuredAgentSessionHost() + const close = vi.spyOn(host!, 'close') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime's own protected close path, reached with the one field it reads. + const internal = runtime as unknown as { + closeStructuredAgentSessionTab(tab: { sessionId: string }, cause: 'user-close'): Promise + } + + await expect( + internal.closeStructuredAgentSessionTab({ sessionId }, 'user-close') + ).resolves.toBeUndefined() + + expect(warn).toHaveBeenCalledWith( + '[structured-agent-session] recording a closed chat tab failed', + expect.objectContaining({ + refusal: expect.objectContaining({ details: { reason: 'journalWrittenByNewerOrca' } }) + }) + ) + expect(close).toHaveBeenCalledWith(sessionId, 'user-close') +}) diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts new file mode 100644 index 000000000000..84c14b2e23cd --- /dev/null +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -0,0 +1,430 @@ +// With native chat on, the renderer's startup also awaits the chat tab restore (`session.tabs.listAll`), +// which reads every chat whose tab was open at quit. That read must not wait on record-store +// bookkeeping: with a saved tab index it writes nothing, and a store that cannot be written costs +// a bounded number of failed writes, not one per chat. + +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' +import { closeTestJournalHostDatabases } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' +import { journalIdentityFor } from '../native-chat/agent-session-wire/structured-agent-session-attach' +import { attachParamsForRecord } from '../native-chat/agent-session-wire/structured-agent-session-conversation-open' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { AgentSessionRecordStore } from './agent-session-record-store' +import type * as AgentSessionRecordRows from './agent-session-record-rows' +import { + AGENT_SESSION_STORE_SCHEMA_VERSION, + legacyAgentSessionStorePath +} from './agent-session-record-store-file' +import { + readPersistedTestAgentSessionStore, + seedTestAgentSessionStoreFromNewerBuild +} from './agent-session-record-store-test-harness' +import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' +import { OrcaRuntimeService } from './orca-runtime' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +// `failing` fails every record write; `grants` lets that many more through, then fails. +const writes = vi.hoisted(() => ({ failing: false, grants: Infinity, refused: 0 })) + +vi.mock('./agent-session-record-rows', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeAgentSessionStoreRows: (...args: Parameters) => { + if (writes.failing || writes.grants <= 0) { + writes.refused += 1 + throw new Error('disk I/O error') + } + writes.grants -= 1 + return actual.writeAgentSessionStoreRows(...args) + } + } +}) + +const PROMPT = 'add a retry' +const CHAT_A = 'chat-a-0001' +const CHAT_B = 'chat-b-0002' +const CLEARED = 'chat-s-0003' +const OWED = 'chat-o-0004' + +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-startup-tab-restore-')) +}) + +afterEach(async () => { + Object.assign(writes, { failing: false, grants: Infinity, refused: 0 }) + await stopStructuredAgentSessionRuntime().catch(() => undefined) + closeTestJournalHostDatabases() + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +/** A released chat, so no startup probe looks for a live owner. */ +function chatRecord( + sessionId: string, + options: { codex?: boolean; clearedInto?: string } = {} +): AgentSessionRecord { + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId, + ownerProcess: null, + reservedSpawnToken: null, + claimStatus: 'released' + }) + ) + const codex = options.codex + ? { + provider: 'codex' as const, + providerHandleChain: record.providerHandleChain.map((link) => ({ + ...link, + handle: { provider: 'codex' as const, threadId: `thread-${sessionId}` } + })), + accountHome: { variable: 'CODEX_HOME' as const, path: join(root, 'codex-home') } + } + : {} + const clear = options.clearedInto + ? { + conversationCommand: { + command: 'clear' as const, + state: 'completed' as const, + phase: 'committed' as const, + operationId: `clear-${sessionId}`, + callerKey: 'client-1', + replacementSessionId: options.clearedInto + } + } + : {} + return { ...record, ...codex, ...clear } +} + +/** The records file a profile from before the chat database carries, which the install imports; + * `visible` is its saved tab index, absent on a legacy profile. `newer` instead leaves the records + * in a database a newer Orca wrote. Then each chat's history as the last run left it: one prompt, + * accepted, so nothing is left to send. */ +async function seedProfile( + records: AgentSessionRecord[], + options: { newer?: boolean; visible?: string[]; history?: AgentSessionRecord[] } = {} +) { + await mkdir(dirname(legacyAgentSessionStorePath(root)), { recursive: true }) + await writeFile( + legacyAgentSessionStorePath(root), + JSON.stringify({ + schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, + hostId: 'local', + records: Object.fromEntries(records.map((record) => [record.sessionId, record])), + operations: {}, + retiredClaimKeys: [], + unusableRecords: {}, + ...(options.visible ? { visibleSessionIds: options.visible } : {}) + }) + ) + const database = await openStructuredAgentSessionJournalDatabase({ + stateDirectory: root, + hostId: 'local', + onLegacyRecordImportReport: () => undefined + }) + for (const record of options.history ?? records) { + const fence = record.lease.runtimeFence + const journal = await openAgentSessionJournal({ + identity: journalIdentityFor( + record, + attachParamsForRecord(record, { clientOperationId: 'seed', expectedRuntimeFence: fence }) + ), + database + }) + await journal.appendSubmission({ + clientMessageId: 'client-1', + payloadFingerprint: 'fp-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: PROMPT }] }, + fence, + handoverRecorded: true + }) + await journal.resolveDispatch({ + clientMessageId: 'client-1', + fence, + state: 'accepted', + providerIdentity: null + }) + await journal.close() + } + database.close() + if (options.newer) { + await seedTestAgentSessionStoreFromNewerBuild(root) + } + return { path: journalDatabasePath(root) } +} + +/** A chat opened, with its tab, while the records file could not be read and the copy was owed. */ +async function seedChatOpenedWhileOwed(record: AgentSessionRecord, tabId: string): Promise { + // A directory where the file belongs: the read fails in a way that can clear. + await mkdir(legacyAgentSessionStorePath(root), { recursive: true }) + const database = await openStructuredAgentSessionJournalDatabase({ + stateDirectory: root, + hostId: 'local', + onLegacyRecordImportReport: () => undefined + }) + database.db + .prepare('INSERT INTO agent_session_records (session_id, record_json) VALUES (?, ?)') + .run(record.sessionId, JSON.stringify(record)) + await AgentSessionRecordStore.open({ + journalDatabase: database, + hostId: 'local' + }).setSessionTabVisibility(record.sessionId, true, tabId) + database.close() + await rm(legacyAgentSessionStorePath(root), { recursive: true }) +} + +function startupRuntime(options: { afterInstall?: () => void; profileChats?: string[] } = {}) { + const onError = vi.fn() + const runtime = new OrcaRuntimeService() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are the runtime's own protected members; the test roots the host at `root`, stubs the PTY daemon and gives it a profile. + const internal = runtime as unknown as { + hasPersistedStructuredAgentSessionStore(): boolean + ensureStructuredAgentSessionHost(): Promise + refreshMobileSessionPtyRecords(): Promise | null> + mobileSessionTabsByWorktree: Map + store: { getWorkspaceSession: () => unknown } + } + internal.hasPersistedStructuredAgentSessionStore = () => true + internal.ensureStructuredAgentSessionHost = async () => { + const installed = await ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveEnvironment: async () => ({}), + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + onError + }) + options.afterInstall?.() + return installed + } + internal.refreshMobileSessionPtyRecords = async () => new Set() + // A legacy profile's saved chat tabs, which a store with no tab index restores from. + internal.store = { + getWorkspaceSession: () => ({ + activeRepoId: null, + activeWorktreeId: 'workspace-1', + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabs: { + 'workspace-1': (options.profileChats ?? []).map((sessionId, index) => ({ + id: `agent-session:${sessionId}`, + entityId: sessionId, + groupId: 'group-1', + worktreeId: 'workspace-1', + contentType: 'agent-session', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: index, + createdAt: 1 + })) + } + }) + } + return { + runtime, + onError, + published: () => internal.mobileSessionTabsByWorktree.get('workspace-1')?.tabs ?? [] + } +} + +async function expectHistory(sessionId: string): Promise { + const host = getStructuredAgentSessionHost() + expect(JSON.stringify((await host!.journalSnapshot(sessionId)).items)).toContain(PROMPT) +} + +function spyOnTabWrites() { + return { + visibility: vi.spyOn(AgentSessionRecordStore.prototype, 'setSessionTabVisibility'), + seed: vi.spyOn(AgentSessionRecordStore.prototype, 'showSessionTabs') + } +} + +async function tabIndexOnDisk(): Promise { + return (await readPersistedTestAgentSessionStore(root)).sessionTabs?.map((tab) => tab.sessionId) +} + +describe('restoring the chat tabs open at quit', () => { + it.each([ + { store: 'records a newer Orca wrote', newer: true, writesFail: false }, + { store: 'a store whose writes keep failing', newer: false, writesFail: true } + ])( + 'lists and reads every chat from $store, and writes nothing', + async ({ newer, writesFail }) => { + const records = [ + chatRecord(CHAT_A), + chatRecord(CHAT_B), + chatRecord(CLEARED, { clearedInto: CHAT_A }) + ] + const { path } = await seedProfile(records, { + newer, + visible: [CHAT_A, CHAT_B], + history: records.slice(0, 2) + }) + const bytes = await readFile(path) + const tabWrites = spyOnTabWrites() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + // Writable when the store opens, then failing for good. + const { runtime, onError, published } = startupRuntime({ + afterInstall: () => { + writes.failing = writesFail + } + }) + + await expect(runtime.restoreStructuredAgentSessionTabs()).resolves.toBeUndefined() + + expect(published().map((tab) => tab.id)).toEqual([ + `agent-session:${CHAT_A}`, + `agent-session:${CHAT_B}` + ]) + expect(published()[0]).toMatchObject({ replacesSessionId: CLEARED }) + await expectHistory(CHAT_A) + await expectHistory(CHAT_B) + // A newer Orca's leases are adjudicated in memory, so nothing fails there. + if (newer) { + expect(onError).not.toHaveBeenCalled() + } else { + expect(onError).toHaveBeenCalledOnce() + expect(onError).toHaveBeenCalledWith({ + scope: 'structured-agent-session-lease-reconcile', + error: expect.objectContaining({ message: 'disk I/O error' }) + }) + } + expect(tabWrites.visibility).not.toHaveBeenCalled() + expect(tabWrites.seed).not.toHaveBeenCalled() + await stopStructuredAgentSessionRuntime() + if (newer) { + expect(await readFile(path)).toEqual(bytes) + } + } + ) + + // Each failed write stands for one refused commit. + it.each([1, 4, 8])('fails one write per startup step with %i chats open', async (count) => { + const chats = Array.from({ length: count }, (_, index) => `chat-${index}-000${index}`) + const records = chats.map((sessionId) => chatRecord(sessionId)) + await seedProfile(records, { visible: chats }) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime, published } = startupRuntime({ + afterInstall: () => { + writes.failing = true + } + }) + + await runtime.prepareStructuredAgentSessionStartupRestoration() + const prepared = writes.refused + await runtime.restoreStructuredAgentSessionTabs() + + expect(published()).toHaveLength(count) + expect(prepared).toBe(1) + expect(writes.refused - prepared).toBe(1) + }) + + describe('on a legacy profile, with no tab index yet', () => { + const legacyChats = () => [ + chatRecord(CHAT_A, { codex: true }), + chatRecord(CHAT_B, { codex: true }) + ] + + it('records every restored chat in one write', async () => { + const records = legacyChats() + await seedProfile(records) + const tabWrites = spyOnTabWrites() + const { runtime, published } = startupRuntime({ profileChats: [CHAT_A, CHAT_B] }) + await runtime.prepareStructuredAgentSessionStartupRestoration() + // One more write, then failing: a seed written chat by chat would stop part way. + writes.grants = 1 + + await runtime.restoreStructuredAgentSessionTabs() + + expect(published().map((tab) => tab.id)).toEqual([ + `agent-session:${CHAT_A}`, + `agent-session:${CHAT_B}` + ]) + expect(await tabIndexOnDisk()).toEqual([CHAT_A, CHAT_B]) + expect(tabWrites.seed).toHaveBeenCalledOnce() + expect(tabWrites.visibility).not.toHaveBeenCalled() + }) + + it('fails one write more, for that one write', async () => { + const records = legacyChats() + await seedProfile(records) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime, published } = startupRuntime({ + profileChats: [CHAT_A, CHAT_B], + afterInstall: () => { + writes.failing = true + } + }) + + await runtime.prepareStructuredAgentSessionStartupRestoration() + const prepared = writes.refused + await runtime.restoreStructuredAgentSessionTabs() + + expect(published()).toHaveLength(2) + expect(prepared).toBe(1) + // The restore's lease check and the seed. + expect(writes.refused - prepared).toBe(2) + }) + + // The profile never lists a Claude chat, so only the tab row that chat left brings it back. + it("restores a chat opened while the copy was owed beside the profile's chats", async () => { + const owed = chatRecord(OWED) + await seedChatOpenedWhileOwed(owed, 'tab-opened-while-owed') + const records = [chatRecord(CHAT_A, { codex: true })] + await seedProfile(records, { history: [owed, ...records] }) + const { runtime, published } = startupRuntime({ profileChats: [CHAT_A] }) + + await runtime.restoreStructuredAgentSessionTabs() + + expect(published().map((tab) => tab.id)).toEqual([ + `agent-session:${OWED}`, + `agent-session:${CHAT_A}` + ]) + expect((await readPersistedTestAgentSessionStore(root)).sessionTabs).toEqual([ + { tabId: 'tab-opened-while-owed', sessionId: OWED }, + { tabId: `structured-agent-session-${CHAT_A}`, sessionId: CHAT_A } + ]) + }) + + it('still lists the chats when that write fails, and leaves the index absent', async () => { + const records = legacyChats() + await seedProfile(records) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime, published } = startupRuntime({ profileChats: [CHAT_A, CHAT_B] }) + await runtime.prepareStructuredAgentSessionStartupRestoration() + writes.failing = true + + await expect(runtime.restoreStructuredAgentSessionTabs()).resolves.toBeUndefined() + + expect(published()).toHaveLength(2) + await expectHistory(CHAT_A) + expect(warn).toHaveBeenCalledWith( + '[structured-agent-session] recording restored chat tabs failed', + { + sessionIds: [CHAT_A, CHAT_B], + error: expect.objectContaining({ message: 'disk I/O error' }) + } + ) + expect(await tabIndexOnDisk()).toBeUndefined() + }) + }) +}) diff --git a/src/main/runtime/structured-agent-session-tab-close-cause.test.ts b/src/main/runtime/structured-agent-session-tab-close-cause.test.ts new file mode 100644 index 000000000000..f90a8674ff87 --- /dev/null +++ b/src/main/runtime/structured-agent-session-tab-close-cause.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { structuredAgentSessionTabCloseCause } from './structured-agent-session-tab-close-cause' + +describe('structuredAgentSessionTabCloseCause', () => { + it.each([ + { label: "an older client's reasonless close", reason: undefined, cause: 'user-close' }, + { label: 'a user close', reason: 'user', cause: 'user-close' }, + { label: 'a cleanup echo', reason: 'cleanup', cause: 'evict' }, + { label: 'a pty-exit echo', reason: 'pty-exit', cause: 'evict' } + ] as const)('closes the chat with $cause for $label', ({ reason, cause }) => { + expect(structuredAgentSessionTabCloseCause(reason)).toBe(cause) + }) +}) diff --git a/src/main/runtime/structured-agent-session-tab-close-cause.ts b/src/main/runtime/structured-agent-session-tab-close-cause.ts new file mode 100644 index 000000000000..143b33831eb3 --- /dev/null +++ b/src/main/runtime/structured-agent-session-tab-close-cause.ts @@ -0,0 +1,17 @@ +import type { RuntimeSessionTabCloseReason } from '../../shared/runtime-types' +import type { StructuredAgentSessionCloseCause } from '../native-chat/agent-session-wire/structured-agent-session-host-lifetime' + +/** Who a chat's `session.tabs.close` speaks for. A reasonless close is an older client's user + * close; a lifecycle echo is not the user's. */ +export function structuredAgentSessionTabCloseCause( + reason: RuntimeSessionTabCloseReason | undefined +): StructuredAgentSessionCloseCause { + switch (reason) { + case undefined: + case 'user': + return 'user-close' + case 'pty-exit': + case 'cleanup': + return 'evict' + } +} diff --git a/src/main/runtime/structured-agent-session-tab-index-seed.ts b/src/main/runtime/structured-agent-session-tab-index-seed.ts new file mode 100644 index 000000000000..188a419f31e2 --- /dev/null +++ b/src/main/runtime/structured-agent-session-tab-index-seed.ts @@ -0,0 +1,36 @@ +// A profile saved before the store kept a tab index restores its chats from the profile's own tabs. +// Those chats are recorded once, all together: an index written part way would read as complete at +// the next launch and drop the rest. With an index present, every restored chat is already listed. + +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' + +type TabIndexHost = Pick< + StructuredAgentSessionHost, + 'getPersistedVisibleSessionTabIndex' | 'showSessionTabs' +> + +/** Best-effort: the tabs publish either way, and an index still absent is seeded again next launch. */ +export async function seedStructuredAgentSessionTabIndex( + host: Partial | null | undefined, + targets: readonly string[], + restored: readonly string[] +): Promise { + if (!host?.getPersistedVisibleSessionTabIndex || !host.showSessionTabs) { + return + } + const index = host.getPersistedVisibleSessionTabIndex() + const listed = new Set(index.present ? index.sessionIds : []) + const opened = new Set(restored) + // In the restore's own order, so the seeded tabs keep the order the profile gave them. + const unlisted = [...new Set([...targets, ...restored])].filter( + (sessionId) => opened.has(sessionId) && !listed.has(sessionId) + ) + if (unlisted.length > 0) { + await host.showSessionTabs(unlisted).catch((error: unknown) => + console.warn('[structured-agent-session] recording restored chat tabs failed', { + sessionIds: unlisted, + error + }) + ) + } +} diff --git a/src/main/runtime/structured-chat-coordinator-fake-codex-fixture.ts b/src/main/runtime/structured-chat-coordinator-fake-codex-fixture.ts index 687569631931..05f5c064ab50 100644 --- a/src/main/runtime/structured-chat-coordinator-fake-codex-fixture.ts +++ b/src/main/runtime/structured-chat-coordinator-fake-codex-fixture.ts @@ -6,6 +6,7 @@ import type { CodexAppServerConnectionHandlers, openCodexAppServerConnection } from '../codex/codex-app-server-connection' +import { refuseUnroutedSteer } from '../codex/codex-structured-dispatch-test-support' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { isRecord } from './rpc/orchestration-session-caller-test-fixture' @@ -16,6 +17,8 @@ export type FakeConnection = Omit & { closed: boolean handlers: CodexAppServerConnectionHandlers threadId: string | null + /** Every JSON-RPC method the adapter called, in order. */ + methods: string[] turns: { clientUserMessageId: string; text: string }[] } @@ -56,10 +59,12 @@ export function fakeCodex() { const connection: FakeConnection = { handlers, threadId: null, + methods: [], turns: [], pid: 4321, closed: false, request: async (method, params) => { + connection.methods.push(method) const input = isRecord(params) ? params : {} if (method === 'thread/start') { connection.threadId = `thread-${connections.length}` @@ -108,6 +113,9 @@ export function fakeCodex() { nextCursor: null } } + if (method === 'turn/steer') { + return refuseUnroutedSteer(undefined) + } return {} }, notify: () => {}, diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index a36912fae955..e9606ea90cb1 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -20,6 +20,7 @@ import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wi import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../shared/agent-session-host-authority' import { refuse } from '../../shared/agent-session-wire-refusals' +import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' import { OrcaRuntimeService } from './orca-runtime' import { OrchestrationDb } from './orchestration/db' import { localOrchestrationCliCommand } from './orchestration/cli-command' @@ -58,7 +59,7 @@ let requests = 0 function request( method: string, params: Record, - options: { sessionId?: string; capability?: string } = {} + options: { sessionId?: string } = {} ): RpcRequest { requests += 1 return { @@ -70,15 +71,14 @@ function request( orchestrationRequestId: `req-${requests}`, ...(options.sessionId ? { orchestrationCompatibilityEvidence: { agentSessionId: options.sessionId } } - : {}), - ...(options.capability ? { orchestrationCapability: options.capability } : {}) + : {}) } } async function call( method: string, params: Record, - options?: { sessionId?: string; capability?: string } + options?: { sessionId?: string } ): Promise> { const response = await dispatcher.dispatch(request(method, params, options)) if (!response.ok) { @@ -98,9 +98,13 @@ async function openChat(sessionId: string): Promise { const threadBySession = new Map() function connectionFor(sessionId: string): FakeConnection { - const connection = codex.connections.findLast( - (candidate) => candidate.threadId === threadBySession.get(sessionId) + // A cleared chat's successor starts on its first message; its record then names its thread. + const head = agentSessionProviderHandleChainHead( + host.deps.store.getRecord(sessionId)?.providerHandleChain ?? [] ) + const thread = + threadBySession.get(sessionId) ?? (head?.handle.provider === 'codex' && head.handle.threadId) + const connection = codex.connections.findLast((candidate) => candidate.threadId === thread) if (!connection) { throw new Error(`no app-server for ${sessionId}`) } @@ -160,7 +164,7 @@ async function userTexts(sessionId: string): Promise { ) } -/** A capability-backed terminal worker under the coordinator's Run, and its worker_done. */ +/** A supervised terminal worker under the coordinator's Run, and its worker_done. */ async function finishWorker( taskId: string, worker: { handle: string; paneKey: string } = { handle: 'term_worker', paneKey: WORKER_PANE } @@ -171,7 +175,7 @@ async function finishWorker( taskId, startOptions: {} }) - const capability = db.prepareStartingWorkerAuthority({ + db.prepareStartingWorkerAuthority({ dispatchId: started.dispatch.id, handle: worker.handle, paneKey: worker.paneKey, @@ -181,16 +185,12 @@ async function finishWorker( setupState: 'not_applicable' }) db.markWorkerDispatchReady(started.dispatch.id) - await call( - 'orchestration.send', - { - from: worker.handle, - subject: 'Done', - type: 'worker_done', - payload: JSON.stringify({ taskId, dispatchId: started.dispatch.id, outcome: 'succeeded' }) - }, - { capability } - ) + await call('orchestration.send', { + from: worker.handle, + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId, dispatchId: started.dispatch.id, outcome: 'succeeded' }) + }) } async function coordinatorRunAndTask(): Promise<{ runId: string; taskId: string }> { @@ -238,10 +238,16 @@ async function clearChat(sessionId: string): Promise { // The surface swaps the tab over to the session that continues the chat. await host.setSessionTabVisibility(sessionId, false) await host.setSessionTabVisibility(successor, true) - threadBySession.set(successor, codex.connections.at(-1)!.threadId!) return successor } +/** A cleared chat's successor runs once the user writes to it; only then can its agent act. */ +async function startSuccessor(successor: string): Promise { + expect(await sendUserMessage(successor, 'hello')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(connectionFor(successor).turns).toHaveLength(1), WAIT) + await settleTurn(successor, 0) +} + beforeEach(async () => { resetProviderFaults() root = await mkdtemp(join(tmpdir(), 'orca-structured-coordinator-mail-')) @@ -468,7 +474,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = it('leaves a pointer the person stopped while its agent was starting stopped', async () => { await openChat(COORDINATOR) const { runId, taskId } = await coordinatorRunAndTask() - await host.close(COORDINATOR) + await host.close(COORDINATOR, 'evict') providerFaults.startDelayMs = 400 const before = providerFaults.starts await finishWorker(taskId) @@ -533,7 +539,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = ): Promise<{ runId: string; starts: number }> { await openChat(COORDINATOR) const { runId, taskId } = await coordinatorRunAndTask() - await host.close(COORDINATOR) + await host.close(COORDINATOR, 'evict') providerFaults.refuseStart = refusal const before = providerFaults.starts await finishWorker(taskId) @@ -677,7 +683,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = await openChat(COORDINATOR) const { taskId } = await coordinatorRunAndTask() // What the idle sweep leaves of a chat nobody is looking at: agent stopped, no map entry. - await host.close(COORDINATOR) + await host.close(COORDINATOR, 'evict') expect(host.hasSession(COORDINATOR)).toBe(false) const before = codex.connections.length @@ -748,15 +754,19 @@ describe('a /clear keeps the chat its orchestration address', () => { const { runId, taskId } = await coordinatorRunAndTask() const generation = db.getRunRaw(runId)!.consumer_generation const successor = await clearChat(COORDINATOR) - const next = connectionFor(successor) + const opened = codex.connections.length - await expect( - call('orchestration.runCurrent', {}, { sessionId: successor }) - ).resolves.toMatchObject({ run: { id: runId } }) + // The worker's result is the successor's first message, which starts it on a new thread. await finishWorker(taskId) - await vi.waitFor(() => expect(next.turns).toHaveLength(1), WAIT) + await vi.waitFor(() => expect(connectionFor(successor).turns).toHaveLength(1), WAIT) + const next = connectionFor(successor) + expect(codex.connections.slice(opened)).toEqual([next]) + expect(next.methods.filter((method) => method.startsWith('thread/'))).toEqual(['thread/start']) expect(next.turns[0]!.text).toMatch(POINTER) await settleTurn(successor, 0) + await expect( + call('orchestration.runCurrent', {}, { sessionId: successor }) + ).resolves.toMatchObject({ run: { id: runId } }) await expect(call('orchestration.check', {}, { sessionId: successor })).resolves.toMatchObject({ runId, count: 1, @@ -769,9 +779,31 @@ describe('a /clear keeps the chat its orchestration address', () => { }) }) + it('points mail the cleared chat never took at the successor, with no message from the user', async () => { + const chat = await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + await finishWorker(taskId) + await vi.waitFor(() => expect(chat.turns).toHaveLength(1), WAIT) + // The provider dies before it runs the pointer, so the mail is still unpointed at the /clear. + chat.handlers.onExit?.(new Error('provider died before the echo')) + await vi.waitFor(() => + expect(host.deps.store.getRecord(COORDINATOR)?.lease.claimStatus).toBe('released') + ) + expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toHaveLength(1) + + const successor = await clearChat(COORDINATOR) + await vi.waitFor(() => expect(connectionFor(successor).turns[0]?.text).toMatch(POINTER), WAIT) + await settleTurn(successor, 0) + await vi.waitFor( + () => expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toEqual([]), + WAIT + ) + }) + it("stores the successor's own Run under the conversation's address, through a chain of clears", async () => { await openChat(COORDINATOR) const middle = await clearChat(COORDINATOR) + await startSuccessor(middle) const created = await call( 'orchestration.runCreate', { objective: 'next' }, @@ -780,7 +812,7 @@ describe('a /clear keeps the chat its orchestration address', () => { const runId = idOf(created.run) expect(db.getRunRaw(runId)!.coordinator_orca_session_id).toBe(COORDINATOR) const successor = await clearChat(middle) - runtime.onStructuredSessionStatusForMail({ sessionId: successor, status: 'idle' }) + await startSuccessor(successor) await expect( call('orchestration.runCurrent', {}, { sessionId: successor }) @@ -803,6 +835,7 @@ describe('a /clear keeps the chat its orchestration address', () => { currentRunCoordinatorOrcaSessionId(db.getRunRaw(runId)!) await openChat(COORDINATOR) const middle = await clearChat(COORDINATOR) + await startSuccessor(middle) const first = idOf( (await call('orchestration.runCreate', { objective: 'first' }, { sessionId: middle })).run ) @@ -817,6 +850,7 @@ describe('a /clear keeps the chat its orchestration address', () => { expect(boundOrcaSessionId(first)).toBeNull() const successor = await clearChat(middle) + await startSuccessor(successor) await call('orchestration.runUse', { id: first }, { sessionId: successor }) const rebound = db.getRunRaw(first)! expect(rebound.coordinator_orca_session_id).toBe(root) @@ -831,8 +865,7 @@ describe('a /clear keeps the chat its orchestration address', () => { await openChat(PEER_CHAT) const middle = await clearChat(PEER_CHAT) const successor = await clearChat(middle) - const next = connectionFor(successor) - + // The first ping starts the live session, which neither clear did. for (const [index, spelling] of [PEER_CHAT, middle, successor].entries()) { const sent = await call('orchestration.send', { from: 'term_worker', @@ -840,7 +873,7 @@ describe('a /clear keeps the chat its orchestration address', () => { subject: `ping ${index}` }) expect(sent).toMatchObject({ message: { to_handle: `session:${PEER_CHAT}` } }) - await vi.waitFor(() => expect(next.turns).toHaveLength(index + 1), WAIT) + await vi.waitFor(() => expect(connectionFor(successor).turns).toHaveLength(index + 1), WAIT) await settleTurn(successor, index) } await expect(call('orchestration.check', {}, { sessionId: successor })).resolves.toMatchObject({ diff --git a/src/main/runtime/structured-child-records-switch.test.ts b/src/main/runtime/structured-child-records-switch.test.ts new file mode 100644 index 000000000000..ea3559e30592 --- /dev/null +++ b/src/main/runtime/structured-child-records-switch.test.ts @@ -0,0 +1,390 @@ +// The switch, end to end on a host with no renderer: a Codex session's frames reach the host's +// canonical store; the status summary every session list reads and the chat strip's channel both +// carry the running children, and the parent row is folded from the same records. +// +// Deliberately NOT a comparison against the provider tracker's task list: that tracker and the +// record producer read the same child executions, so agreeing with it could not catch a defect in +// what those executions observe. Every expectation below is written out literally. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentSessionBackgroundTaskState, + AgentSessionStatusSummary +} from '../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../shared/agent-status-child-work-view' +import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' +import type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + openCodexAppServerConnection +} from '../codex/codex-app-server-connection' +import { AgentHookServer, _internals } from '../agent-hooks/server' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { StructuredAgentSessionStatusSink } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) + +const THREAD = 'thread-switch' +const REVIEWER = 'thread-reviewer' +const ROUTES: Record = { + 'thread/start': { thread: { id: THREAD } }, + 'model/list': { + data: [ + { + model: 'gpt-test', + displayName: 'GPT Test', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + isDefault: true + } + ], + nextCursor: null + } +} + +type Row = Pick & { + outcome?: AgentChildWorkView['outcome'] + tool?: string + owner?: string +} + +/** What a surface would show of each child, independent of which channel carried it. */ +function rows(views: readonly AgentChildWorkView[] | undefined): Row[] { + const byId = new Map((views ?? []).map((view) => [view.id, view])) + return (views ?? []).map((view) => ({ + kind: view.kind, + ...(view.description ? { description: view.description } : {}), + state: view.state, + membership: view.membership, + ...(view.outcome ? { outcome: view.outcome } : {}), + ...(view.operation ? { tool: `${view.operation.toolName}: ${view.operation.input}` } : {}), + ...(view.parentChildWorkId + ? { owner: byId.get(view.parentChildWorkId)?.description ?? '?' } + : {}) + })) +} + +describe('the chat strip and the session list read the same host child records', () => { + let root: string + let server: AgentHookServer + + beforeEach(async () => { + _internals.resetCachesForTests() + root = await mkdtemp(join(tmpdir(), 'orca-child-records-switch-')) + server = new AgentHookServer() + }) + + afterEach(async () => { + await stopStructuredAgentSessionRuntime() + await rm(root, { recursive: true, force: true }) + }) + + it('carries running children to both, hides the strip when none runs, and folds the parent row from them', async () => { + const connections: CodexAppServerConnectionHandlers[] = [] + const openConnection: typeof openCodexAppServerConnection = async (_launch, handlers = {}) => { + connections.push(handlers) + const connection: CodexAppServerConnection = { + pid: 4321, + closed: false, + request: async (method) => (method in ROUTES ? ROUTES[method] : {}), + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => true + } + return connection + } + // Production's sink wiring, onto a real hook server: no renderer is involved anywhere. + const statusSink: StructuredAgentSessionStatusSink = { + publish: (summary, subject) => server.ingestStructuredStatus(summary, subject), + forget: (subject) => server.dropStructuredStatus(subject), + publishChildWork: (subject, evidence, provider) => + server.ingestStructuredChildWork(subject, evidence, provider), + readChildWork: (subject) => server.getStructuredChildWorkViews(subject) + } + const host = await ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveCodexCommand: () => 'codex', + resolveEnvironment: async () => ({ PATH: process.env.PATH }), + openCodexConnection: openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + statusSink + }) + const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) + attachParams.envelope.clientOperationId = `${Date.now()}-${'1'.padStart(32, '0')}` + const attached = await host.attach({ callerKey: 'switch-test' }, attachParams) + expect(attached).toMatchObject({ ok: true }) + const fence = attached.ok ? attached.value.fence : 0 + const summaries: AgentSessionStatusSummary[] = [] + host.subscribeStatus({ + id: 'session-list', + emit: (event) => { + if (event.type === 'status') { + summaries.push(event.session) + } else if (event.type === 'snapshot') { + summaries.push(...event.sessions.filter((session) => session.sessionId === SESSION)) + } + } + }) + const strip: (AgentSessionBackgroundTaskState | null)[] = [] + await host.subscribe({ + id: 'strip', + sessionId: SESSION, + emit: (event) => { + if ('backgroundTasks' in event && event.backgroundTasks !== undefined) { + strip.push(event.backgroundTasks) + } + } + }) + // Journal writes land asynchronously; let each frame's rows publish before the next. + const settle = async () => { + for (let tick = 0; tick < 5; tick += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + } + const notify = async (method: string, params: Record) => { + connections[0]?.onNotification?.(method, params) + await settle() + } + const item = ( + method: 'item/started' | 'item/completed', + threadId: string, + turnId: string, + value: Record + ) => notify(method, { threadId, turnId, item: value }) + const parentRow = () => server.getStatusSnapshot().find((row) => row.structuredHost) + const summaryRows = () => rows(summaries.at(-1)?.children) + const stripRows = () => rows(strip.at(-1)?.children ?? undefined) + const both = (expected: Row[]) => { + expect(summaryRows()).toEqual(expected) + expect(stripRows()).toEqual(expected) + } + + await notify('turn/started', { threadId: THREAD, turn: { id: 'p1', status: 'inProgress' } }) + await item('item/completed', THREAD, 'p1', { + type: 'agentMessage', + id: 'lead-1', + text: 'Spawning a reviewer' + }) + await notify('turn/started', { threadId: REVIEWER, turn: { id: 'r1', status: 'inProgress' } }) + await item('item/started', THREAD, 'p1', { + type: 'subAgentActivity', + id: 'spawn-1', + kind: 'started', + agentThreadId: REVIEWER, + agentPath: '/root/review' + }) + both([{ kind: 'agent', description: 'review', state: 'working', membership: 'live' }]) + + await item('item/started', REVIEWER, 'r1', { + type: 'commandExecution', + id: 'cmd-1', + command: 'npm test', + source: 'agent', + status: 'inProgress' + }) + await item('item/started', REVIEWER, 'r1', { + type: 'commandExecution', + id: 'exec-1', + command: 'npm run dev', + source: 'unifiedExecStartup', + status: 'inProgress' + }) + // Every command the child runs is a live record under it, whatever Codex tagged it. + both([ + { + kind: 'agent', + description: 'review', + state: 'working', + membership: 'live', + tool: 'Bash: npm run dev' + }, + { + kind: 'command', + description: 'npm test', + state: 'working', + membership: 'live', + owner: 'review' + }, + { + kind: 'command', + description: 'npm run dev', + state: 'working', + membership: 'live', + owner: 'review' + } + ]) + + // A command that exits leaves nothing behind. + await item('item/completed', REVIEWER, 'r1', { + type: 'commandExecution', + id: 'cmd-1', + command: 'npm test', + source: 'unifiedExecStartup', + status: 'completed', + exitCode: 0 + }) + both([ + { + kind: 'agent', + description: 'review', + state: 'working', + membership: 'live', + tool: 'Bash: npm run dev' + }, + { + kind: 'command', + description: 'npm run dev', + state: 'working', + membership: 'live', + owner: 'review' + } + ]) + + // The parent's turn ends first: its own work is done, a subagent's is not. + await notify('turn/completed', { threadId: THREAD, turn: { id: 'p1', status: 'completed' } }) + expect(parentRow()).toMatchObject({ state: 'working' }) + expect(parentRow()).not.toHaveProperty('workingMode') + + // The child finishes; the shell it launched still runs. + await notify('turn/completed', { threadId: REVIEWER, turn: { id: 'r1', status: 'completed' } }) + both([ + { + kind: 'agent', + description: 'review', + state: 'done', + membership: 'settled', + outcome: 'succeeded' + }, + { + kind: 'command', + description: 'npm run dev', + state: 'working', + membership: 'live', + owner: 'review' + } + ]) + expect(parentRow()).toMatchObject({ state: 'working', workingMode: 'monitoring' }) + // An older client folds the summary's task list: only live work is on it. + expect(summaries.at(-1)?.backgroundTasks?.map((task) => task.kind)).toEqual(['command']) + + await item('item/completed', REVIEWER, 'r1', { + type: 'commandExecution', + id: 'exec-1', + command: 'npm run dev', + source: 'unifiedExecStartup', + status: 'completed', + exitCode: 0 + }) + expect(parentRow()).toMatchObject({ state: 'done' }) + // Nothing runs: the sidebar and the strip list nothing, and the strip hides. The finished + // child's record stays in the store until the user's next turn; the dev server's record went + // when its process exited. + expect(summaries.at(-1)).not.toHaveProperty('children') + expect(strip.at(-1)).toBeNull() + const stored = () => rows(server.getStructuredChildWorkViews(parentSubject(summaries))) + expect(stored()).toEqual([ + { + kind: 'agent', + description: 'review', + state: 'done', + membership: 'settled', + outcome: 'succeeded' + } + ]) + + // A turn nobody sent retires nothing: only the user's next message does. + await notify('turn/started', { threadId: THREAD, turn: { id: 'p-own', status: 'inProgress' } }) + await notify('turn/completed', { threadId: THREAD, turn: { id: 'p-own', status: 'completed' } }) + expect(stored()).toHaveLength(1) + // The user's next message, which Codex takes into its next turn. + const body = hostTestMessage('check it again') + const clientOperationId = `${Date.now()}-${'2'.padStart(32, '0')}` + await host.send( + { callerKey: 'switch-test' }, + { + envelope: { + sessionId: SESSION, + clientOperationId, + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + } + ) + await notify('turn/started', { threadId: THREAD, turn: { id: 'p2', status: 'inProgress' } }) + await notify('item/started', { + threadId: THREAD, + turn: { id: 'p2' }, + item: { type: 'userMessage', id: 'user-2', clientId: clientOperationId } + }) + await vi.waitFor(() => expect(stored()).toEqual([])) + expect(strip.at(-1)).toBeNull() + expect(summaries.at(-1)).not.toHaveProperty('children') + + // The reviewer's next run is still going when the provider exits: it settles with an outcome + // nobody reported, and leaves both lists. + await notify('turn/started', { threadId: REVIEWER, turn: { id: 'r2', status: 'inProgress' } }) + both([{ kind: 'agent', description: 'review', state: 'working', membership: 'live' }]) + connections[0]?.onExit?.(new Error('scripted provider exit')) + await settle() + expect(summaries.at(-1)).not.toHaveProperty('children') + expect(strip.at(-1)).toBeNull() + expect(stored()).toEqual([ + { + kind: 'agent', + description: 'review', + state: 'done', + membership: 'settled', + outcome: 'unknown' + } + ]) + + // Closing its tab lets go of the session: its row, and every child record with it, leave the + // store. + const subject = parentSubject(summaries) + await host.setSessionTabVisibility(SESSION, false) + await host.close(SESSION, 'user-close') + expect(server.getStructuredChildWorkViews(subject)).toEqual([]) + expect(summaries.at(-1)).not.toHaveProperty('children') + expect(summaries.at(-1)).not.toHaveProperty('backgroundTasks') + }) +}) + +function parentSubject(summaries: AgentSessionStatusSummary[]) { + const summary = summaries.at(-1) + if (!summary) { + throw new Error('no summary') + } + return makeStructuredAgentStatusSubject( + { + executionHostId: 'local', + wslDistro: null, + workspaceId: summary.workspaceId, + workspaceKind: 'git-worktree' + }, + summary.sessionId + ) +} diff --git a/src/main/runtime/structured-claude-pending-rewind.test.ts b/src/main/runtime/structured-claude-pending-rewind.test.ts index 137b7ff6961f..c521ef8b6d95 100644 --- a/src/main/runtime/structured-claude-pending-rewind.test.ts +++ b/src/main/runtime/structured-claude-pending-rewind.test.ts @@ -100,7 +100,7 @@ async function seedPendingRewind(phase: 'prepared' | 'provider-succeeded') { } async function reattach() { - await host.close(HOST_TEST_SESSION) + await host.close(HOST_TEST_SESSION, 'evict') expect(await host.attach(caller, attachParams(fence()))).toMatchObject({ ok: true }) } diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 761a6835a555..9024c65b503d 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -1,6 +1,5 @@ import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' import type { AgentSessionRecord } from '../../shared/agent-session-record' -import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import { resolveClaudeCommand } from '../codex-cli/command' import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' import { createClaudeStructuredLaunchResolver } from '../claude/claude-structured-launch-resolution' @@ -17,6 +16,7 @@ import { } from './agent-session-provider-handle-transition' import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import type { AgentSessionRecordStore } from './agent-session-record-store' +import { ClaudeAtRestCommandCatalog } from '../claude/claude-at-rest-commands' export type StructuredClaudeRuntimeAdapterDeps = { store: AgentSessionRecordStore @@ -35,10 +35,6 @@ export type StructuredClaudeRuntimeAdapterDeps = { readProcessStartTime?: ClaudeStructuredSessionAdapterDeps['readProcessStartTime'] modelCatalog?: ClaudeStructuredSessionAdapterDeps['modelCatalog'] onLifecycleEvent: (event: StructuredAgentSessionLifecycleEvent) => void - onBackgroundTasksChanged?: ( - sessionId: string, - state: AgentSessionBackgroundTaskState | null - ) => void onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate'] onSessionIdle?: ClaudeStructuredSessionAdapterDeps['onSessionIdle'] onChildWorkEvidence?: ClaudeStructuredSessionAdapterDeps['onChildWorkEvidence'] @@ -78,6 +74,9 @@ export function createStructuredClaudeRuntimeAdapter( ): ClaudeStructuredSessionAdapter { const { store } = deps return new ClaudeStructuredSessionAdapter({ + atRestCommands: new ClaudeAtRestCommandCatalog({ + resolveWorkspacePath: deps.resolveWorkspacePath + }), resolveLaunch: createClaudeStructuredLaunchResolver({ store, resolveWorkspacePath: deps.resolveWorkspacePath, @@ -129,9 +128,6 @@ export function createStructuredClaudeRuntimeAdapter( deps.onLifecycleEvent(lifecycle) } }, - ...(deps.onBackgroundTasksChanged - ? { onBackgroundTasksChanged: deps.onBackgroundTasksChanged } - : {}), ...(deps.onDispatchSettledLate ? { onDispatchSettledLate: deps.onDispatchSettledLate } : {}), ...(deps.onSessionIdle ? { onSessionIdle: deps.onSessionIdle } : {}), ...(deps.onChildWorkEvidence ? { onChildWorkEvidence: deps.onChildWorkEvidence } : {}), diff --git a/src/main/runtime/structured-codex-session-rpc-test-harness.ts b/src/main/runtime/structured-codex-session-rpc-test-harness.ts index 6b9024ba0294..9d6c0ef30fbc 100644 --- a/src/main/runtime/structured-codex-session-rpc-test-harness.ts +++ b/src/main/runtime/structured-codex-session-rpc-test-harness.ts @@ -12,6 +12,7 @@ import type { CodexAppServerConnectionHandlers, openCodexAppServerConnection } from '../codex/codex-app-server-connection' +import { refuseUnroutedSteer } from '../codex/codex-structured-dispatch-test-support' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import { AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, @@ -99,6 +100,9 @@ function fakeCodex(): FakeCodex { nextCursor: null } } + if (method === 'turn/steer') { + return refuseUnroutedSteer(undefined) + } return {} }, notify: () => {}, diff --git a/src/main/runtime/structured-worker-at-rest.test.ts b/src/main/runtime/structured-worker-at-rest.test.ts index 28317e4da884..669a655b2bc4 100644 --- a/src/main/runtime/structured-worker-at-rest.test.ts +++ b/src/main/runtime/structured-worker-at-rest.test.ts @@ -189,7 +189,7 @@ describe('an open dispatch keeps its worker running (P2-19 i)', () => { const handle = resource.terminal_handle const recipients = () => listAddressableStructuredWorkers(db) // At rest, its dispatch abandoned: still a recipient, as a terminal worker left running is. - db.abandonWorkerDispatch(dispatch.id) + db.abandonWorkerDispatch(dispatch.id, 'epoch_test') const worktreeGroup = () => resolveGroupAddress('@worktree:wt_1', 'term_sender', recipients(), () => 'idle') expect(worktreeGroup()).toEqual([handle]) @@ -337,7 +337,7 @@ describe('a task dispatched into a worker whose own dispatch settled', () => { await vi.waitFor(() => expect(observeStructuredWorker({ sessionId: REST_TEST_SESSION }).status).toBe('exited') ) - expect(rig.adapter.closeSession).toHaveBeenCalledWith(REST_TEST_SESSION) + expect(rig.adapter.closeSession).toHaveBeenCalledWith(REST_TEST_SESSION, 'evict') } finally { hostRef.current = null await rig.dispose() diff --git a/src/main/runtime/structured-worker-identity.ts b/src/main/runtime/structured-worker-identity.ts index 22a49e11abb2..ababbfbd86cd 100644 --- a/src/main/runtime/structured-worker-identity.ts +++ b/src/main/runtime/structured-worker-identity.ts @@ -108,7 +108,7 @@ function persistedStructuredWorkerPaneKeyIsValid( * * NOT the runtime fence: the fence is an owner-generation counter that the host bumps during its * own transparent crash recovery, so fencing identity on it would make a recovered — but same — - * worker fail `verifyDispatchCapability` forever and wedge release as `identity_unproven`. The + * worker fail its process check forever and wedge release as `identity_unproven`. The * session id is minted once per dispatch and survives that recovery, so it is the lineage. */ export function structuredWorkerProcessIncarnation(sessionId: string): string { diff --git a/src/main/runtime/terminal-wait-detection.ts b/src/main/runtime/terminal-wait-detection.ts index 1c05c697fb10..f1f5aa6c81c4 100644 --- a/src/main/runtime/terminal-wait-detection.ts +++ b/src/main/runtime/terminal-wait-detection.ts @@ -8,6 +8,7 @@ import { import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' import type { TuiAgent } from '../../shared/tui-agent' import { findAntigravityReadyPromptIndex } from './antigravity-terminal-readiness' +import { readScreenRuledVerdict } from './screen-ruled-agent-readiness' import { findCodexHeaderIndex, findCodexScreenReadyPromptIndex, @@ -15,7 +16,8 @@ import { isCodexProvisionalStartupText } from './codex-terminal-readiness' import { findStartupDialogBlockedSignals } from './startup-dialog-blocked-signals' -import { startOfLastLines, startOfLastNonBlankLines } from './terminal-wait-tail-window' +import { startOfLastNonBlankLines } from './terminal-wait-tail-window' +import { findCursorApprovalPromptIndex } from './cursor-approval-prompt-detection' const EXPLICIT_IDLE_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i const CLAUDE_IDLE_PREFIX = '\u2733' @@ -75,8 +77,8 @@ function isReadyPromptSettled(normalized: string, readyIndex: number | null): bo * Tier 1 body evidence for every tui-idle site. `readScreenLines` yields the live emulator's * visible grid, or null when the runtime has no trustworthy one. * - * Why not for a clocked Codex pane: its header stays up through every turn, so it proves only - * that Codex started; isQuietReadyScreenBody holds it to quiescence instead. + * Why not for a clocked Codex or screen-ruled pane: its header or composer is also painted + * mid-turn, so isQuietReadyScreenBody holds it to quiescence instead. * Why a clockless pane keeps it: quiescence needs an output clock, which a restored pane lacks. */ export function isKnownReadyPromptBody( @@ -88,6 +90,10 @@ export function isKnownReadyPromptBody( if (agent === 'qoder') { return isQoderComposerReady(readScreenLines()) } + const screenVerdict = readScreenRuledVerdict(agent, readScreenLines) + if (screenVerdict !== null) { + return screenVerdict && !hasOutputClock + } if (agent === 'codex' && hasOutputClock) { return false } @@ -105,7 +111,7 @@ export function isKnownReadyPromptBody( /** * Tier 1b body evidence: a ready screen from an agent with no title rest signal. Unlike tier 1 * it only proves the TUI is up, so the ranking holds it to quiescence. - * Why codex panes only: a `cat`ed transcript or pager in an unknown pane can show the composer. + * Why identified panes only: a `cat`ed transcript or pager in an unknown pane can show the composer. */ export function isQuietReadyScreenBody( waitText: string, @@ -124,6 +130,9 @@ export function isQuietReadyScreenBody( const normalized = waitText.toLowerCase() return isReadyPromptSettled(normalized, findCodexReadyPromptIndex(normalized)) } + if (readScreenRuledVerdict(agent, readScreenLines) === true) { + return true + } return (agent === null || agent === 'muse') && isMuseReadyPromptPreview(waitText) } @@ -242,51 +251,6 @@ function findCodexReadyPromptIndex(normalized: string): number | null { export const TERMINAL_WAIT_BLOCKED_SENTINEL_RE = /update available|choose working directory to|codex just got an upgrade|available\s*·|esc\s*skip|enter\s*confirm\s*·|enter\/esc\s*(?:continue|confirm)|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust|permission required|requires permission|allow once|allow always|run this command\?/i -// Why text at all: cursor-agent has no approval hook, so the key-bound menu is the only authority. -const CURSOR_APPROVAL_CHOICE_MARKERS = [ - 'run (once)', - 'to allowlist?', - 'run everything', - 'skip & tell the agent' -] -// Why bounded: an answered menu remains in scrollback; only a dialog owning the screen bottom is live. -const CURSOR_APPROVAL_TAIL_LINES = 8 - -function findCursorApprovalPromptIndex(normalized: string): number | null { - const windowStart = startOfLastLines(normalized, CURSOR_APPROVAL_TAIL_LINES) - const tail = normalized.slice(windowStart) - if (!tail.includes('run this command?')) { - return null - } - const lines = tail.split('\n') - while (lines.length > 0 && lines.at(-1)?.trim() === '') { - lines.pop() - } - let matchedLines = 0 - let lastChoiceLine = -1 - for (let index = 0; index < lines.length; index += 1) { - if (!isCursorApprovalChoiceLine(lines[index])) { - continue - } - matchedLines += 1 - lastChoiceLine = index - } - return matchedLines >= 2 && lastChoiceLine === lines.length - 1 - ? windowStart + tail.lastIndexOf('run this command?') - : null -} - -// Why the trailing key: narration can repeat the menu wording, but it does not end in a selectable key. -const CURSOR_APPROVAL_CHOICE_KEY_RE = - /\((?:shift\+tab|ctrl\+[a-z]|esc(?: or [a-z])*|tab|enter|return|space|[a-z]|[\u21b5\u21e7\u21b9\u238b\u23ce]{1,3})\)\s*$/ - -function isCursorApprovalChoiceLine(line: string): boolean { - return ( - CURSOR_APPROVAL_CHOICE_KEY_RE.test(line) && - CURSOR_APPROVAL_CHOICE_MARKERS.some((marker) => line.includes(marker)) - ) -} - // Why bounded: answered dialogs and quoted prompt wording (agents grep this file and its specs) stay in the // retained tail; only a dialog owning the screen bottom is live. Real Codex dialogs (trust, hooks review, // update, exec approval) are 4-8 lines; the slack covers a wrapped command or a longer hook list. diff --git a/src/main/runtime/tui-idle-evidence.test.ts b/src/main/runtime/tui-idle-evidence.test.ts index 818d96545106..c97949607f76 100644 --- a/src/main/runtime/tui-idle-evidence.test.ts +++ b/src/main/runtime/tui-idle-evidence.test.ts @@ -4,6 +4,7 @@ import { getSyntheticAgentTerminalTitle } from '../../shared/synthetic-agent-tit import { isTuiAgent, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import { getTuiAgentRestSignal } from '../../shared/tui-agent-rest-signal' import { isKnownReadyPromptBody } from './terminal-wait-detection' +import { getScreenReadyRule, readScreenRuledVerdict } from './screen-ruled-agent-readiness' import { evaluateTuiIdle, hasFreshDoneFirstPartyStatus, @@ -31,6 +32,7 @@ function input(overrides: Partial = {}): TuiIdleEvaluati readTailBlockedReason: () => null, readPositiveBodyEvidence: () => false, readQuietReadyBodyEvidence: () => true, + readScreenDecidesReadiness: () => false, agent: 'muse', firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS, @@ -206,6 +208,27 @@ describe('rest signal agrees with the lanes that can settle a wait', () => { false ) const quietScreenBody = hasQuietReadyScreen(record(), agent, () => true, QUIESCENCE_MS) + // Why a screen-ruled `none` is sound: its screen shuts the quiet lane whenever one is readable. + const rule = getScreenReadyRule(agent) + if (rule !== null) { + const refused = ['> not an idle composer'] + expect(rule(refused)).toBe(false) + const verdict = (screen: readonly string[] | null) => + evaluateTuiIdle( + input({ + agent, + record: record({ lastOscTitle: null }), + readQuietReadyBodyEvidence: () => false, + readScreenDecidesReadiness: () => readScreenRuledVerdict(agent, () => screen) !== null + }) + ) + expect(verdict(refused)).toEqual({ kind: 'pending', quietForeground: 'closed' }) + expect(verdict(null)).toEqual({ + kind: 'pending', + quietForeground: signal === 'none' ? 'after-paint' : 'closed' + }) + return + } // Why not only ready-body: Codex keeps its stronger hook-driven title beside this lane. if (quietScreenBody) { expect(signal).not.toBe('none') @@ -247,6 +270,7 @@ describe('a DSH pane settles tui-idle on its own hook', () => { rendererTitle: undefined, readPositiveBodyEvidence: () => false, readQuietReadyBodyEvidence: () => false, + readScreenDecidesReadiness: () => false, readTailBlockedReason: () => null, agent: 'dsh' as const, firstPartyStatus: { state: 'done' as const, updatedAt: Date.now() }, diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts index 3df42c82166c..76ab9143a4bb 100644 --- a/src/main/runtime/tui-idle-evidence.ts +++ b/src/main/runtime/tui-idle-evidence.ts @@ -16,6 +16,7 @@ import { isKnownReadyPromptBody, isQuietReadyScreenBody } from './terminal-wait-detection' +import { getScreenReadyRule, readScreenRuledVerdict } from './screen-ruled-agent-readiness' /** * Ranking the evidence that a `tui-idle` wait may settle on. @@ -29,8 +30,9 @@ import { * 0. BLOCKED — the tail shows a prompt waiting on the user. * 1. STRONG READY — the agent states it is ready: an explicit idle marker in its own * title, or a known ready-prompt body. - * 1b. QUIET READY SCREEN — Muse and an idle Codex title no rest signal, so their - * ready-screen body stands in for the strong evidence, believed only once quiet. + * 1b. QUIET READY SCREEN — Muse and an idle Codex title no rest signal, and the screen-ruled + * agents (Antigravity, Cline, Prime Agent) can paint their idle composer mid-turn, so their + * ready-screen body is believed only once quiet. * 2. WORKING — a fresh first-party agent status (OSC 9999) saying working/blocked/ * waiting, or a working title. The agent's own account of itself outranks anything * inferred. @@ -196,8 +198,10 @@ export type TuiIdleEvaluationInput = { * (~11us and a multi-KB string on a full tail); the title check below usually answers * first, and then none of that has to happen at all. */ readPositiveBodyEvidence: () => boolean - /** Tier 1b body evidence: a Muse or Codex ready screen. Thunk for the same reason as above. */ + /** Tier 1b body evidence: a Muse, Codex or screen-ruled ready screen. Thunk, as above. */ readQuietReadyBodyEvidence: () => boolean + /** Whether the agent's live screen already ruled on readiness, which shuts the weak lanes. */ + readScreenDecidesReadiness: () => boolean agent: TuiAgent | null | undefined firstPartyStatus: FirstPartyAgentStatus quiescenceMs: number @@ -225,8 +229,9 @@ const QUIET_READY_SCREEN_AGENTS: ReadonlySet = new Set(['muse', 'codex * the cwd (plus a thread name) and no agent name, so neither the explicit-idle nor the * sustained-title lane can fire. The ready screen proves the TUI is up; the quiescence * demand keeps a mid-turn streaming pane from satisfying, mirroring the tier-3 lane's - * positive-evidence-plus-quiet shape. Scoped to those agents and agent-unknown panes (which - * read only Muse's screen): another agent's scrollback quoting them must not settle its wait. + * positive-evidence-plus-quiet shape. Scoped to those agents, the screen-ruled ones, and + * agent-unknown panes (which read only Muse's screen): another agent's scrollback quoting them + * must not settle its wait. */ export function hasQuietReadyScreen( record: TuiIdleEvidenceRecord, @@ -234,7 +239,7 @@ export function hasQuietReadyScreen( readBodyEvidence: () => boolean, quiescenceMs: number ): boolean { - if (agent && !QUIET_READY_SCREEN_AGENTS.has(agent)) { + if (agent && !QUIET_READY_SCREEN_AGENTS.has(agent) && !getScreenReadyRule(agent)) { return false } // Why: same rule as the tier-3 lane — without an output clock there is no @@ -297,6 +302,10 @@ export function evaluateTuiIdle(input: TuiIdleEvaluationInput): TuiIdleVerdict { if (input.record.lastAgentStatus === 'working') { return WORKING } + // Why: a name-only title and a quiet process cannot see the picker or prompt the screen refused. + if (input.readScreenDecidesReadiness()) { + return { kind: 'pending', quietForeground: 'closed' } + } if (hasSustainedTitleIdle(input.record, input.agent, input.quiescenceMs)) { return READY_WEAK } @@ -319,6 +328,20 @@ export type TuiIdleEvidenceSource = { getPaneAgent(ptyId: string | null | undefined): TuiAgent | null getFirstPartyAgentStatus(ptyId: string | null | undefined): FirstPartyAgentStatus readScreenLines(ptyId: string | null | undefined): readonly string[] | null + /** The painted rows on the PTY's own grid, which only screen-ruled agents read. Absent, they + * have no trustworthy screen. */ + readScreenRuledLines?(ptyId: string | null | undefined): readonly string[] | null +} + +// Why per agent table: every other agent keeps the screen it read before screen rules existed. +function screenReader( + source: TuiIdleEvidenceSource, + agent: TuiAgent | null, + ptyId: string | null | undefined +): () => readonly string[] | null { + return getScreenReadyRule(agent) + ? () => source.readScreenRuledLines?.(ptyId) ?? null + : () => source.readScreenLines(ptyId) } function lazyWaitText(readWaitText: () => string): () => string { @@ -333,19 +356,15 @@ export function leafTuiIdleEvidence( ): TuiIdleEvaluationInput { const waitText = lazyWaitText(readWaitText) const agent = source.getPaneAgent(leaf.ptyId) + const readScreen = screenReader(source, agent, leaf.ptyId) return { record: leaf, readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText()), rendererTitle: leaf.paneTitle ?? source.getTabTitle(leaf.tabId), readPositiveBodyEvidence: () => - isKnownReadyPromptBody( - waitText(), - agent, - () => source.readScreenLines(leaf.ptyId), - leaf.lastOutputAt !== null - ), - readQuietReadyBodyEvidence: () => - isQuietReadyScreenBody(waitText(), agent, () => source.readScreenLines(leaf.ptyId)), + isKnownReadyPromptBody(waitText(), agent, readScreen, leaf.lastOutputAt !== null), + readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText(), agent, readScreen), + readScreenDecidesReadiness: () => readScreenRuledVerdict(agent, readScreen) !== null, agent, firstPartyStatus: source.getFirstPartyAgentStatus(leaf.ptyId), quiescenceMs: source.quiescenceMs @@ -359,19 +378,15 @@ export function ptyTuiIdleEvidence( ): TuiIdleEvaluationInput { const waitText = lazyWaitText(readWaitText) const agent = source.getPaneAgent(pty.ptyId) + const readScreen = screenReader(source, agent, pty.ptyId) return { record: pty, readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText()), readPositiveBodyEvidence: () => (agent !== 'qoder' && source.getAdoptedPtyIdleStatus(pty) === 'idle') || - isKnownReadyPromptBody( - waitText(), - agent, - () => source.readScreenLines(pty.ptyId), - pty.lastOutputAt !== null - ), - readQuietReadyBodyEvidence: () => - isQuietReadyScreenBody(waitText(), agent, () => source.readScreenLines(pty.ptyId)), + isKnownReadyPromptBody(waitText(), agent, readScreen, pty.lastOutputAt !== null), + readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText(), agent, readScreen), + readScreenDecidesReadiness: () => readScreenRuledVerdict(agent, readScreen) !== null, agent, firstPartyStatus: source.getFirstPartyAgentStatus(pty.ptyId), quiescenceMs: source.quiescenceMs diff --git a/src/main/sqlite/bun-readonly-wal.test.ts b/src/main/sqlite/bun-readonly-wal.test.ts deleted file mode 100644 index f60482c8e0ed..000000000000 --- a/src/main/sqlite/bun-readonly-wal.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import * as fs from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' -import { initializeBunReadonlyWal } from './bun-readonly-wal' -import Database from './sync-database' - -vi.mock('node:fs', async (importOriginal) => { - const original = await importOriginal() - return { ...original, openSync: vi.fn(original.openSync) } -}) - -const directories: string[] = [] - -afterEach(async () => { - vi.restoreAllMocks() - const original = await vi.importActual('node:fs') - vi.mocked(fs.openSync).mockImplementation(original.openSync) - for (const directory of directories.splice(0)) { - fs.rmSync(directory, { recursive: true, force: true }) - } -}) - -function fixture(wal = true): string { - const directory = fs.mkdtempSync(join(tmpdir(), 'orca-readonly-wal-')) - directories.push(directory) - const file = join(directory, 'state.db') - const db = new Database(file) - db.exec('CREATE TABLE state (id INTEGER PRIMARY KEY, value TEXT)') - if (wal) { - db.pragma('journal_mode=WAL') - } - db.close() - return file -} - -describe('readonly WAL initialization', () => { - it.each(['EACCES', 'EPERM', 'EROFS'])( - 'lets SQLite decide read access when WAL creation fails with %s', - async (code) => { - const file = fixture() - const { openSync: open } = await vi.importActual('node:fs') - vi.mocked(fs.openSync).mockImplementation((path, flags, ...rest) => { - if (path === `${file}-wal` && flags === 'wx') { - throw Object.assign(new Error('read-only directory'), { code }) - } - return open(path, flags, ...rest) - }) - expect(() => initializeBunReadonlyWal(file)).not.toThrow() - expect(fs.existsSync(`${file}-wal`)).toBe(false) - } - ) - - it('still reports unexpected WAL creation failures', async () => { - const file = fixture() - const { openSync: open } = await vi.importActual('node:fs') - vi.mocked(fs.openSync).mockImplementation((path, flags, ...rest) => { - if (path === `${file}-wal` && flags === 'wx') { - throw Object.assign(new Error('disk failure'), { code: 'EIO' }) - } - return open(path, flags, ...rest) - }) - expect(() => initializeBunReadonlyWal(file)).toThrow('disk failure') - }) - - it('allows a clean WAL database to reopen without changing its bytes or admitting SQL writes', () => { - const file = fixture() - const bytes = fs.readFileSync(file) - initializeBunReadonlyWal(file) - if (process.platform !== 'win32') { - expect(fs.statSync(`${file}-wal`).mode & 0o777).toBe(0o600) - } - const reader = new Database(file, { readonly: true }) - try { - expect(reader.prepare('SELECT COUNT(*) AS count FROM state').get()).toEqual({ count: 0 }) - expect(() => reader.exec("INSERT INTO state VALUES(1,'write')")).toThrow() - } finally { - reader.close() - } - expect(fs.readFileSync(file)).toEqual(bytes) - }) - - it('leaves a concurrent writer’s newly published WAL intact', async () => { - const file = fixture() - const { openSync: open } = await vi.importActual('node:fs') - vi.mocked(fs.openSync).mockImplementation((path, flags, ...rest) => { - if (path === `${file}-wal` && flags === 'wx') { - fs.writeFileSync(path, 'concurrent writer evidence') - } - return open(path, flags, ...rest) - }) - initializeBunReadonlyWal(file) - expect(fs.readFileSync(`${file}-wal`, 'utf8')).toBe('concurrent writer evidence') - }) - - it('leaves a current writer’s WAL bytes intact', () => { - const file = fixture() - const writer = new Database(file) - try { - writer.exec("INSERT INTO state VALUES(1,'durable')") - const before = fs.readFileSync(`${file}-wal`) - initializeBunReadonlyWal(file) - expect(fs.readFileSync(`${file}-wal`)).toEqual(before) - const reader = new Database(file, { readonly: true }) - try { - expect(reader.prepare('SELECT value FROM state').get()).toEqual({ value: 'durable' }) - } finally { - reader.close() - } - } finally { - writer.close() - } - }) - - it.skipIf(process.platform === 'win32')('opens a cold WAL database through a symlink', () => { - const file = fixture() - const alias = join(directories[0]!, 'alias.db') - fs.symlinkSync(file, alias) - const reader = new Database(alias, { readonly: true }) - try { - expect(reader.prepare('SELECT COUNT(*) AS count FROM state').get()).toEqual({ count: 0 }) - expect(fs.existsSync(`${alias}-wal`)).toBe(false) - } finally { - reader.close() - } - }) - - it('does not add recovery evidence to a rollback-journal database or malformed file', () => { - const file = fixture(false) - initializeBunReadonlyWal(file) - expect(fs.existsSync(`${file}-wal`)).toBe(false) - fs.writeFileSync(file, 'not SQLite') - initializeBunReadonlyWal(file) - expect(fs.existsSync(`${file}-wal`)).toBe(false) - }) - - it.skipIf(process.platform !== 'darwin' || !process.versions.bun)( - 'preserves another connection’s exclusive lock while inspecting the database header', - async () => { - const file = fixture(false) - const writer = new Database(file) - const probe = () => - runProcess({ - program: process.execPath, - args: [ - '-e', - `const { Database } = require('bun:sqlite') - const db = new Database(process.argv[1], { readonly: true }) - try { db.prepare('SELECT * FROM state').all(); process.stdout.write('readable') } - catch (error) { process.stdout.write(error.code) } - finally { db.close(true) }`, - file - ], - timeoutMs: 5_000 - }) - try { - writer.exec("BEGIN EXCLUSIVE; INSERT INTO state VALUES(1,'uncommitted')") - expect(await probe()).toMatchObject({ code: 0, stdout: 'SQLITE_BUSY' }) - const reader = new Database(file, { readonly: true }) - reader.close() - expect(await probe()).toMatchObject({ code: 0, stdout: 'SQLITE_BUSY' }) - } finally { - writer.close() - } - } - ) - - it.skipIf(process.platform === 'win32')( - 'does not follow an existing dangling WAL symlink', - () => { - const file = fixture() - const outside = join(directories[0]!, 'unrelated') - fs.symlinkSync(outside, `${file}-wal`) - initializeBunReadonlyWal(file) - expect(fs.existsSync(outside)).toBe(false) - expect(fs.lstatSync(`${file}-wal`).isSymbolicLink()).toBe(true) - } - ) -}) diff --git a/src/main/sqlite/bun-readonly-wal.ts b/src/main/sqlite/bun-readonly-wal.ts deleted file mode 100644 index b7cbd39880a8..000000000000 --- a/src/main/sqlite/bun-readonly-wal.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { closeSync, existsSync, openSync, readSync } from 'node:fs' - -/** Match SQLite's WAL creation without permitting writes through the database connection. */ -export function initializeBunReadonlyWal(path: string): void { - const wal = `${path}-wal` - if (existsSync(wal) || !hasWalHeader(path)) { - return - } - try { - // Apple's SQLite requires an existing WAL; exclusive creation preserves every existing byte. - closeSync(openSync(wal, 'wx', 0o600)) - } catch (error) { - if ( - typeof error !== 'object' || - error === null || - !('code' in error) || - !['EEXIST', 'EACCES', 'EPERM', 'EROFS'].includes(String(error.code)) - ) { - throw error - } - } -} - -function hasWalHeader(path: string): boolean { - const file = openSync(path, 'r') - try { - const header = Buffer.alloc(20) - return ( - readSync(file, header, 0, header.length, 0) === header.length && - header.subarray(0, 16).toString() === 'SQLite format 3\0' && - header[18] === 2 && - header[19] === 2 - ) - } finally { - closeSync(file) - } -} diff --git a/src/main/sqlite/bun-sqlite-database.ts b/src/main/sqlite/bun-sqlite-database.ts deleted file mode 100644 index b9695e08781a..000000000000 --- a/src/main/sqlite/bun-sqlite-database.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { DatabaseSync } from 'node:sqlite' -import { fileURLToPath, pathToFileURL } from 'node:url' -import { BunSqliteStatement, type BunStatement } from './bun-sqlite-statement' -import { initializeBunReadonlyWal } from './bun-readonly-wal' - -type BunDatabase = { - exec(sql: string): void - prepare(sql: string): BunStatement - readonly inTransaction: boolean - close(throwOnError: boolean): void - fileControl(command: number, value: Int32Array): number -} - -type BunSqlite = { - Database: new (path: string, flags: number) => BunDatabase -} - -const SQLITE_OPEN_READONLY = 0x01 -const SQLITE_OPEN_READWRITE = 0x02 -const SQLITE_OPEN_CREATE = 0x04 -const SQLITE_OPEN_URI = 0x40 -const SQLITE_FCNTL_PERSIST_WAL = 10 - -export function loadBunSqlite(): BunSqlite | undefined { - if (!process.versions.bun || typeof process.getBuiltinModule !== 'function') { - return undefined - } - const sqlite: unknown = process.getBuiltinModule('bun:sqlite') - return isBunSqlite(sqlite) ? sqlite : undefined -} - -function isBunSqlite(value: unknown): value is BunSqlite { - return ( - typeof value === 'object' && - value !== null && - 'Database' in value && - typeof value.Database === 'function' - ) -} - -export class BunSqliteDatabase { - private readonly database: BunDatabase - - constructor( - path: ConstructorParameters[0], - options: { readonly?: boolean; fileMustExist?: boolean; timeout?: number } = {} - ) { - const sqlite = loadBunSqlite() - if (!sqlite) { - throw new Error('SQLite is unavailable in this runtime') - } - const filename = - path instanceof URL ? fileURLToPath(path) : typeof path === 'string' ? path : path.toString() - const flags = options.readonly - ? SQLITE_OPEN_READONLY - : SQLITE_OPEN_READWRITE | (options.fileMustExist ? 0 : SQLITE_OPEN_CREATE) - this.database = new sqlite.Database( - filename === ':memory:' || filename === '' ? filename : sqliteFileUri(filename), - // URI parsing defaults differ between the platform SQLite libraries. - flags | SQLITE_OPEN_URI - ) - try { - if (process.platform === 'darwin' && filename !== ':memory:' && filename !== '') { - // Apple's default retains WAL files; match the other shipped SQLite drivers. - if (this.database.fileControl(SQLITE_FCNTL_PERSIST_WAL, new Int32Array(2)) !== 0) { - throw new Error('SQLite cannot configure WAL cleanup') - } - if (options.readonly) { - const statement = this.database.prepare('PRAGMA database_list') - try { - const path = statement.get()?.file - if (typeof path !== 'string' || path.length === 0) { - throw new Error('SQLite did not report its database filename') - } - // SQLite resolves symlinks before locating its sidecars. - initializeBunReadonlyWal(path) - } finally { - statement.finalize() - } - } - } - const timeout = options.timeout ?? 0 - if (!Number.isSafeInteger(timeout) || timeout < 0 || timeout > 2_147_483_647) { - throw new RangeError('SQLite busy timeout must be a nonnegative 32-bit integer') - } - this.database.exec(`PRAGMA foreign_keys = ON; PRAGMA busy_timeout = ${timeout}`) - } catch (error) { - this.database.close(true) - throw error - } - } - - exec(sql: string): void { - this.database.exec(sql) - } - - prepare(sql: string): BunSqliteStatement { - return new BunSqliteStatement(this.database.prepare(sql), () => this.database.prepare(sql)) - } - - get isTransaction(): boolean { - return this.database.inTransaction - } - - /** Logical snapshot; implicit rowids may change. The caller runs this in its backup worker. */ - backup(path: string): void { - const statement = this.database.prepare('VACUUM INTO ?') - try { - statement.run(sqliteFileUri(path)) - } finally { - statement.finalize() - } - } - - close(): void { - this.database.close(true) - } -} - -function sqliteFileUri(path: string): string { - const url = pathToFileURL(path) - if (url.hostname) { - const hostname = url.hostname - url.hostname = '' - // SQLite accepts UNC paths with an empty URI authority on Windows. - url.pathname = `//${hostname}${url.pathname}` - } - return url.href -} diff --git a/src/main/sqlite/bun-sqlite-statement.ts b/src/main/sqlite/bun-sqlite-statement.ts deleted file mode 100644 index 01b8081721a2..000000000000 --- a/src/main/sqlite/bun-sqlite-statement.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { SQLInputValue, StatementResultingChanges } from 'node:sqlite' -import { SqliteIntegerReader } from './sqlite-integer-reader' -import type { SqliteBindings, SqliteRow, SqliteStatement } from './sqlite-statement' - -type BunBindings = (SQLInputValue | SQLInputValue[])[] -const EMPTY_BINDINGS: BunBindings = [[]] - -export type BunStatement = { - all(...parameters: BunBindings): SqliteRow[] - get(...parameters: BunBindings): SqliteRow | null - run(...parameters: BunBindings): StatementResultingChanges - iterate(...parameters: BunBindings): IterableIterator - finalize(): void - safeIntegers(enabled: boolean): void - readonly paramsCount: number -} - -export class BunSqliteStatement implements SqliteStatement { - private readonly integers = new SqliteIntegerReader() - private readonly parameterCount: number - - constructor( - private readonly statement: BunStatement, - private readonly prepareIterator: () => BunStatement - ) { - statement.safeIntegers(true) - this.parameterCount = statement.paramsCount - } - - all(...parameters: SqliteBindings): SqliteRow[] { - const rows = this.statement.all(...this.bindings(parameters)) - for (const row of rows) { - this.integers.row(row) - } - return rows - } - - get(...parameters: SqliteBindings): SqliteRow | undefined { - const row = this.statement.get(...this.bindings(parameters)) - return row === null ? undefined : this.integers.row(row) - } - - run(...parameters: SqliteBindings): StatementResultingChanges { - return this.integers.result(this.statement.run(...this.bindings(parameters))) - } - - *iterate(...parameters: SqliteBindings): IterableIterator { - // Bun leaves interrupted iterators positioned on their last row and exposes no reset. - const statement = this.prepareIterator() - try { - statement.safeIntegers(true) - for (const row of statement.iterate(...this.bindings(parameters))) { - yield this.integers.row(row) - } - } finally { - statement.finalize() - } - } - - setReadBigInts(enabled: boolean): void { - this.integers.readBigInts = enabled - } - - private bindings(parameters: SqliteBindings): BunBindings { - if (parameters.some((value) => value === undefined)) { - throw new TypeError('Undefined cannot be bound to a SQLite parameter') - } - // No arguments would reuse the driver's previous bindings. - if (parameters.length === 0) { - return EMPTY_BINDINGS - } - if (parameters.length >= this.parameterCount) { - return parameters - } - return [...parameters, ...Array(this.parameterCount - parameters.length).fill(null)] - } -} diff --git a/src/main/sqlite/node-sqlite-reader-api.test.ts b/src/main/sqlite/node-sqlite-reader-api.test.ts new file mode 100644 index 000000000000..5818457684cd --- /dev/null +++ b/src/main/sqlite/node-sqlite-reader-api.test.ts @@ -0,0 +1,30 @@ +import { runInNewContext } from 'node:vm' +import { describe, expect, it } from 'vitest' +import { hasNodeSqliteReaderApi, NODE_SQLITE_READER_API_SOURCE } from './node-sqlite-reader-api' + +const probe = (sqlite: unknown): unknown => + runInNewContext(`(${NODE_SQLITE_READER_API_SOURCE})(sqlite)`, { sqlite }) + +class DatabaseSync {} +const backup = async (): Promise => {} + +describe('node:sqlite reader admission', () => { + it.each([ + ['DatabaseSync with backup (Node >= 22.16)', { DatabaseSync, backup }, true], + ['DatabaseSync without backup (Node 22.13-22.15)', { DatabaseSync }, false], + ['backup without DatabaseSync', { backup }, false], + ['non-function DatabaseSync', { DatabaseSync: {}, backup }, false], + ['non-function backup', { DatabaseSync, backup: true }, false], + ['missing module', undefined, false], + ['null module', null, false] + ])('agrees in-process and in the embedded probe for %s', (_label, sqlite, admitted) => { + expect(hasNodeSqliteReaderApi(sqlite)).toBe(admitted) + expect(probe(sqlite)).toBe(admitted) + }) + + it('admits the actual runtime module', () => { + const sqlite: unknown = process.getBuiltinModule('node:sqlite') + expect(hasNodeSqliteReaderApi(sqlite)).toBe(true) + expect(probe(sqlite)).toBe(true) + }) +}) diff --git a/src/main/sqlite/node-sqlite-reader-api.ts b/src/main/sqlite/node-sqlite-reader-api.ts new file mode 100644 index 000000000000..84a5f94a100b --- /dev/null +++ b/src/main/sqlite/node-sqlite-reader-api.ts @@ -0,0 +1,20 @@ +/** + * Source of the node:sqlite admission check, embedded in remote/WSL probe scripts so a + * host's Node passes exactly when `isSqliteAvailable()` would admit it there. + * Why `backup`: it landed with the DatabaseSync options SyncDatabase relies on (Node 22.16), + * so Node 22.13–22.15 must take the pinned-runtime fallback. + */ +export const NODE_SQLITE_READER_API_SOURCE = + "(m)=>typeof m==='object'&&m!==null&&typeof m.DatabaseSync==='function'&&typeof m.backup==='function'" + +/** In-process twin of {@link NODE_SQLITE_READER_API_SOURCE}; a parity test keeps them equal. */ +export function hasNodeSqliteReaderApi(sqlite: unknown): boolean { + return ( + typeof sqlite === 'object' && + sqlite !== null && + 'DatabaseSync' in sqlite && + typeof sqlite.DatabaseSync === 'function' && + 'backup' in sqlite && + typeof sqlite.backup === 'function' + ) +} diff --git a/src/main/sqlite/sqlite-read-failure.test.ts b/src/main/sqlite/sqlite-read-failure.test.ts index fdb3dc883658..336b2920d5a8 100644 --- a/src/main/sqlite/sqlite-read-failure.test.ts +++ b/src/main/sqlite/sqlite-read-failure.test.ts @@ -54,7 +54,7 @@ describe('isTransientSqliteContention', () => { } } - expect(thrown).toMatchObject({ [process.versions.bun ? 'errno' : 'errcode']: 5 }) + expect(thrown).toMatchObject({ errcode: 5 }) expect(isTransientSqliteContention(thrown)).toBe(true) }) diff --git a/src/main/sqlite/sqlite-read-failure.ts b/src/main/sqlite/sqlite-read-failure.ts index 6919bf9e9cbf..498bf5c1de12 100644 --- a/src/main/sqlite/sqlite-read-failure.ts +++ b/src/main/sqlite/sqlite-read-failure.ts @@ -18,16 +18,13 @@ function primaryErrcode(error: unknown): number | null { if (!error || typeof error !== 'object') { return null } - const errcode = 'errcode' in error ? error.errcode : 'errno' in error ? error.errno : undefined + const errcode = 'errcode' in error ? error.errcode : undefined return typeof errcode === 'number' && Number.isFinite(errcode) ? errcode & 0xff : null } -/** node:sqlite marks its errors `ERR_SQLITE_ERROR`; Bun names its class `SQLiteError`. */ +/** node:sqlite marks its errors `ERR_SQLITE_ERROR`. */ function isSqliteDriverError(error: unknown): boolean { - return ( - error instanceof Error && - (('code' in error && error.code === 'ERR_SQLITE_ERROR') || error.name === 'SQLiteError') - ) + return error instanceof Error && 'code' in error && error.code === 'ERR_SQLITE_ERROR' } /** True only when SQLite itself reports the database damaged or not a database at all. */ diff --git a/src/main/sqlite/sync-database-concurrent-backup.test.ts b/src/main/sqlite/sync-database-concurrent-backup.test.ts index 94b6221e1d64..6c58b72d9db4 100644 --- a/src/main/sqlite/sync-database-concurrent-backup.test.ts +++ b/src/main/sqlite/sync-database-concurrent-backup.test.ts @@ -7,9 +7,7 @@ import SyncDatabase from './sync-database' const WRITER_SOURCE = ` const { parentPort, workerData } = require('node:worker_threads') - const Database = process.versions.bun - ? require('bun:sqlite').Database - : require('node:sqlite').DatabaseSync + const Database = require('node:sqlite').DatabaseSync const db = new Database(workerData.path) db.exec('PRAGMA busy_timeout=5000; PRAGMA synchronous=FULL') const count = new Int32Array(workerData.count) diff --git a/src/main/sqlite/sync-database.test.ts b/src/main/sqlite/sync-database.test.ts index 4c937197a43c..bd3d6882b58b 100644 --- a/src/main/sqlite/sync-database.test.ts +++ b/src/main/sqlite/sync-database.test.ts @@ -213,7 +213,7 @@ describe('SyncDatabase read-only opens under contention', () => { thrown = error } - expect(thrown).toMatchObject({ [process.versions.bun ? 'errno' : 'errcode']: 5 }) + expect(thrown).toMatchObject({ errcode: 5 }) expect((thrown as Error).message).toContain('database is locked') expect(Date.now() - startedAt).toBeLessThan(200) }) diff --git a/src/main/sqlite/sync-database.ts b/src/main/sqlite/sync-database.ts index 5f27b52329e8..90c07baeb6c6 100644 --- a/src/main/sqlite/sync-database.ts +++ b/src/main/sqlite/sync-database.ts @@ -1,6 +1,6 @@ import { existsSync } from 'node:fs' import type { backup, BackupOptions, DatabaseSync, SQLInputValue } from 'node:sqlite' -import { BunSqliteDatabase, loadBunSqlite } from './bun-sqlite-database' +import { hasNodeSqliteReaderApi } from './node-sqlite-reader-api' import { NodeSqliteStatement } from './node-sqlite-statement' import type { SqliteStatement } from './sqlite-statement' @@ -62,33 +62,26 @@ function hasBackup(value: unknown): value is { backup: typeof backup } { export function isSqliteAvailable(): boolean { try { - if (process.versions.bun) { - return loadBunSqlite() !== undefined - } const sqlite: unknown = process.getBuiltinModule?.('node:sqlite') - return hasDatabaseSync(sqlite) && hasBackup(sqlite) + return hasNodeSqliteReaderApi(sqlite) } catch { return false } } class SyncDatabase { - private readonly db: DatabaseSync | BunSqliteDatabase + private readonly db: DatabaseSync private readonly statementCache = new Map() constructor(path: SqlitePath, options: SyncDatabaseOptions = {}) { if (options.fileMustExist && path !== ':memory:' && !existsSync(path)) { throw new Error(`SQLite database does not exist: ${String(path)}`) } - if (process.versions.bun) { - this.db = new BunSqliteDatabase(path, options) - } else { - const DatabaseSync = loadDatabaseSync() - this.db = new DatabaseSync(path, { - readOnly: options.readonly, - timeout: options.timeout - }) - } + const DatabaseSync = loadDatabaseSync() + this.db = new DatabaseSync(path, { + readOnly: options.readonly, + timeout: options.timeout + }) } exec(sql: string): void { @@ -106,10 +99,7 @@ class SyncDatabase { this.statementCache.set(sql, cached) return cached } - const statement = - this.db instanceof BunSqliteDatabase - ? this.db.prepare(sql) - : new NodeSqliteStatement(this.db.prepare(sql)) + const statement = new NodeSqliteStatement(this.db.prepare(sql)) if (isStatementCacheable(sql)) { if (this.statementCache.size >= STATEMENT_CACHE_LIMIT) { const oldest = this.statementCache.keys().next().value @@ -138,18 +128,11 @@ class SyncDatabase { return this.db.isTransaction } - /** Keep the source open until completion; Bun's compact snapshot runs synchronously. */ + /** Keep the source open until completion. */ async backup(path: string, options?: BackupOptions): Promise { if (this.db.isTransaction) { throw new Error('SQLite backup requires an idle database connection') } - if (this.db instanceof BunSqliteDatabase) { - if (options && Object.keys(options).length > 0) { - throw new Error('Incremental SQLite backup options are unavailable in this runtime') - } - this.db.backup(path) - return - } const sqlite: unknown = typeof process.getBuiltinModule === 'function' ? process.getBuiltinModule('node:sqlite') diff --git a/src/main/ssh/orcad-activation-record.test.ts b/src/main/ssh/orcad-activation-record.test.ts index dd625238bb12..4950045daa5e 100644 --- a/src/main/ssh/orcad-activation-record.test.ts +++ b/src/main/ssh/orcad-activation-record.test.ts @@ -92,6 +92,14 @@ describe('orcad activation record', () => { expect(pinned).toEqual(['orcad-0.3.0+cc01', 'orcad-0.2.0+bb01', 'orcad-0.1.0+aa01']) }) + // Design D7.1 R4; the real two-slot swap is orcad-cross-runtime-daemon-adoption.integration.test.ts. + it('pins a Bun-era slot whose live daemon a Node orcad adopted, rollback target or not', () => { + const bunSlotVersion = '0.1.0+ea9eae1d1d6a' + const nodeRecord = { ...emptyOrcadActivationRecord(), active: '0.1.0+a97ad77bf77c' } + expect(orcadGcPinnedDirNames(nodeRecord, bunSlotVersion)).toContain(`orcad-${bunSlotVersion}`) + expect(orcadGcPinnedDirNames(nodeRecord)).not.toContain(`orcad-${bunSlotVersion}`) + }) + it('deduplicates pins when the live daemon came from the active bundle', () => { const pinned = orcadGcPinnedDirNames( { ...emptyOrcadActivationRecord(), active: '0.3.0+cc01', previous: null }, diff --git a/src/main/ssh/orcad-artifact-materializer.test.ts b/src/main/ssh/orcad-artifact-materializer.test.ts index beff61176eea..bbeeb3ea9f33 100644 --- a/src/main/ssh/orcad-artifact-materializer.test.ts +++ b/src/main/ssh/orcad-artifact-materializer.test.ts @@ -1,7 +1,6 @@ import { createHash } from 'node:crypto' import { chmodSync, - existsSync, mkdirSync, mkdtempSync, readFileSync, @@ -12,31 +11,26 @@ import { import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { NODE_RUNTIME_ASSETS, type ServerTarget } from '../../shared/node-runtime-pin' import { ORCAD_BUILD_TARGET_FILENAME, - ORCAD_EMOJI_SHORTCODE_DATASET, - ORCAD_RIPGREP_ARTIFACTS, + ORCAD_NODE_RUNTIME_MARKER_FILENAME, + ORCAD_SERVER_TARGET_FILENAME, ORCAD_TEMPLATE_MANIFEST_FILENAME, ORCAD_TEMPLATE_TARGETS_DIR, ORCAD_VERSION_FILENAME, orcadArtifactFilenames, - orcadTemplateCommonFilenames + orcadRipgrepArtifact, + orcadTemplateCommonFilenames, + orcadTemplateTargetFilenames } from '../../shared/orcad-artifacts' -import type { OrcadBunTarget } from '../../shared/orcad-bun-runtime' -import { z } from 'zod' import { readOrcadArtifactIdentity } from '../orcad/orcad-artifact-identity' import { assembleOrcadArtifact, materializeOrcadArtifact, resetOrcadArtifactMaterializationsForTests } from './orcad-artifact-materializer' -import { materializeCachedOrcadBunRuntime } from './orcad-bun-runtime-materializer' -import type * as BunRuntimeMaterializer from './orcad-bun-runtime-materializer' - -vi.mock('./orcad-bun-runtime-materializer', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, materializeCachedOrcadBunRuntime: vi.fn() } -}) const TARGET = 'linux-x64-glibc' as const const temporaryDirs: string[] = [] @@ -58,104 +52,96 @@ function write(path: string, contents: string): void { writeFileSync(path, contents) } -function createTemplate(target: OrcadBunTarget = TARGET): { - root: string +const ManifestSchema = z + .object({ + targets: z.record( + z.string(), + z.object({ files: z.record(z.string(), z.string()) }).passthrough() + ), + commonSha256: z.record(z.string(), z.string()) + }) + .passthrough() + +function targetContents(target: ServerTarget, filename: string): string { + if (filename === ORCAD_SERVER_TARGET_FILENAME) { + return `${target}\n` + } + if (filename === ORCAD_NODE_RUNTIME_MARKER_FILENAME) { + return `${NODE_RUNTIME_ASSETS[target].executableSha256}\n` + } + return `${target}:${filename}` +} + +function createTemplate(target: ServerTarget = TARGET): { templateDir: string cacheRoot: string - runtimePath: string } { const root = mkdtempSync(join(tmpdir(), 'orcad-artifact-template-')) temporaryDirs.push(root) const templateDir = join(root, 'template') const cacheRoot = join(root, 'cache') - const runtimePath = join(root, 'bun-runtime') - const common: Record = { - ...Object.fromEntries(orcadTemplateCommonFilenames().map((filename) => [filename, filename])), - 'orcad.js': 'orcad-entry', - 'daemon-entry.js': 'daemon-entry', - 'profile-state-writer-worker-entry.js': 'writer-entry', - 'profile-state-backup-worker-entry.js': 'backup-entry', - 'windows-bun-pty-gate-entry.js': 'pty-gate-entry', - 'parcel-watcher-process-entry.js': 'watcher-process', - 'node_modules/@parcel/watcher/index.js': 'watcher-wrapper', - [ORCAD_EMOJI_SHORTCODE_DATASET]: '{}' - } - for (const [filename, contents] of Object.entries(common)) { - write(join(templateDir, filename), contents) + const commonSha256: Record = {} + for (const filename of orcadTemplateCommonFilenames()) { + write(join(templateDir, filename), filename === 'orcad.js' ? 'orcad-entry' : filename) + commonSha256[filename] = sha256(join(templateDir, filename)) } const targetDir = join(templateDir, ORCAD_TEMPLATE_TARGETS_DIR, target) - write(join(targetDir, ORCAD_BUILD_TARGET_FILENAME), `${target}\n`) - write(join(targetDir, 'watcher.node'), 'native-watcher') - write(join(targetDir, 'agent-browser-linux-x64'), 'browser') - write(runtimePath, 'bun-executable') - if (target.startsWith('win32-')) { - write(join(templateDir, 'windows-process-tree.node'), 'process-table') + const files: Record = {} + for (const filename of orcadTemplateTargetFilenames(target)) { + write(join(targetDir, filename), targetContents(target, filename)) + files[filename] = sha256(join(targetDir, filename)) } + write(join(targetDir, 'agent-browser-linux-x64'), 'browser') write( join(templateDir, ORCAD_TEMPLATE_MANIFEST_FILENAME), JSON.stringify({ - schemaVersion: 2, - commonSha256: Object.fromEntries( - Object.keys(common).map((filename) => [filename, sha256(join(templateDir, filename))]) - ), + schemaVersion: 3, + commonSha256, targets: { [target]: { - targetSha256: sha256(join(targetDir, ORCAD_BUILD_TARGET_FILENAME)), - watcherSha256: sha256(join(targetDir, 'watcher.node')), + files, browserName: 'agent-browser-linux-x64', browserSha256: sha256(join(targetDir, 'agent-browser-linux-x64')) } } }) ) - return { root, templateDir, cacheRoot, runtimePath } + return { templateDir, cacheRoot } +} + +function rewriteManifest( + templateDir: string, + change: (manifest: z.infer) => void +): void { + const manifestPath = join(templateDir, ORCAD_TEMPLATE_MANIFEST_FILENAME) + const manifest = ManifestSchema.parse(JSON.parse(readFileSync(manifestPath, 'utf8'))) + change(manifest) + write(manifestPath, JSON.stringify(manifest)) } describe('assembleOrcadArtifact', () => { it.skipIf(process.platform === 'win32')( - 'restores executable search modes from a template copied without them', + 'restores executable modes from a template copied without them', async () => { - const fixture = createTemplate() - const filenames = ORCAD_RIPGREP_ARTIFACTS.filter((filename) => filename.endsWith('/rg')) - for (const filename of filenames) { - chmodSync(join(fixture.templateDir, filename), 0o644) + const target = 'darwin-arm64' as const + const fixture = createTemplate(target) + const executables = [ + orcadRipgrepArtifact(target), + 'node_modules/node-pty/build/Release/spawn-helper' + ] + for (const filename of executables) { + chmodSync(join(fixture.templateDir, ORCAD_TEMPLATE_TARGETS_DIR, target, filename), 0o644) } - const artifactDir = await assembleOrcadArtifact({ ...fixture, target: TARGET }) - for (const filename of filenames) { + const artifactDir = await assembleOrcadArtifact({ ...fixture, target }) + for (const filename of executables) { expect(statSync(join(artifactDir, filename)).mode & 0o777).toBe(0o755) } } ) - it('gives Windows executable naming a new immutable slot identity', async () => { - const target = 'win32-x64' as const - const fixture = createTemplate(target) - const artifactDir = await assembleOrcadArtifact({ ...fixture, target }) - expect(readFileSync(join(artifactDir, 'bun-runtime.exe'), 'utf8')).toBe('bun-executable') - expect(existsSync(join(artifactDir, 'bun-runtime'))).toBe(false) - const oldHash = createHash('sha256') - for (const filename of orcadArtifactFilenames(target)) { - oldHash.update(readFileSync(join(artifactDir, filename))) - } - oldHash.update('browser') - const oldVersion = `0.1.0+${oldHash.digest('hex').slice(0, 12)}` - const oldDir = join(fixture.cacheRoot, target, oldVersion) - write(join(oldDir, 'bun-runtime'), 'legacy-slot-must-stay-unchanged') - expect(artifactDir).not.toBe(oldDir) - await expect(assembleOrcadArtifact({ ...fixture, target })).resolves.toBe(artifactDir) - expect(readFileSync(join(oldDir, 'bun-runtime'), 'utf8')).toBe( - 'legacy-slot-must-stay-unchanged' - ) - }) - - it('assembles a complete content-addressed target directory', async () => { + it('assembles a complete content-addressed slot that references the pinned Node', async () => { const fixture = createTemplate() - const artifactDir = await assembleOrcadArtifact({ - templateDir: fixture.templateDir, - cacheRoot: fixture.cacheRoot, - target: TARGET, - runtimePath: fixture.runtimePath - }) + const artifactDir = await assembleOrcadArtifact({ ...fixture, target: TARGET }) const version = readFileSync(join(artifactDir, ORCAD_VERSION_FILENAME), 'utf8').trim() expect(version).toMatch(/^0\.1\.0\+[a-f0-9]{12}$/u) @@ -163,28 +149,35 @@ describe('assembleOrcadArtifact', () => { write(join(artifactDir, 'orcad.js'), 'changed-installed-bytes') expect(await readOrcadArtifactIdentity(artifactDir)).not.toBe(version) expect(artifactDir).toBe(join(fixture.cacheRoot, TARGET, version)) - expect(readFileSync(join(artifactDir, ORCAD_BUILD_TARGET_FILENAME), 'utf8').trim()).toBe(TARGET) - for (const filename of orcadArtifactFilenames()) { + expect(readFileSync(join(artifactDir, ORCAD_SERVER_TARGET_FILENAME), 'utf8').trim()).toBe( + TARGET + ) + expect(readFileSync(join(artifactDir, ORCAD_NODE_RUNTIME_MARKER_FILENAME), 'utf8').trim()).toBe( + NODE_RUNTIME_ASSETS[TARGET].executableSha256 + ) + for (const filename of orcadArtifactFilenames(TARGET)) { expect(readFileSync(join(artifactDir, filename)).byteLength).toBeGreaterThan(0) } + // A Bun-era client's selector would exit 78 on this file without a Bun runtime (R5). + expect(() => readFileSync(join(artifactDir, ORCAD_BUILD_TARGET_FILENAME))).toThrow() expect(readFileSync(join(artifactDir, 'agent-browser-linux-x64'), 'utf8')).toBe('browser') }) it('rejects a packaged native file that does not match its manifest', async () => { const fixture = createTemplate() write( - join(fixture.templateDir, ORCAD_TEMPLATE_TARGETS_DIR, TARGET, 'watcher.node'), + join( + fixture.templateDir, + ORCAD_TEMPLATE_TARGETS_DIR, + TARGET, + 'node_modules/node-pty/build/Release/pty.node' + ), 'corrupted' ) - await expect( - assembleOrcadArtifact({ - templateDir: fixture.templateDir, - cacheRoot: fixture.cacheRoot, - target: TARGET, - runtimePath: fixture.runtimePath - }) - ).rejects.toThrow('watcher checksum mismatch') + await expect(assembleOrcadArtifact({ ...fixture, target: TARGET })).rejects.toThrow( + 'node_modules/node-pty/build/Release/pty.node checksum mismatch' + ) }) it('rejects a self-consistent target marker for a different native slot', async () => { @@ -193,41 +186,39 @@ describe('assembleOrcadArtifact', () => { fixture.templateDir, ORCAD_TEMPLATE_TARGETS_DIR, TARGET, - ORCAD_BUILD_TARGET_FILENAME + ORCAD_SERVER_TARGET_FILENAME ) write(targetPath, 'linux-x64-musl\n') - const manifestPath = join(fixture.templateDir, ORCAD_TEMPLATE_MANIFEST_FILENAME) - const manifest = z - .object({ - targets: z.record(z.string(), z.object({ targetSha256: z.string() }).passthrough()) - }) - .passthrough() - .parse(JSON.parse(readFileSync(manifestPath, 'utf8'))) - const target = manifest.targets[TARGET] - if (!target) { - throw new Error('Missing target fixture') - } - target.targetSha256 = sha256(targetPath) - write(manifestPath, JSON.stringify(manifest)) + rewriteManifest(fixture.templateDir, (manifest) => { + manifest.targets[TARGET]!.files[ORCAD_SERVER_TARGET_FILENAME] = sha256(targetPath) + }) - await expect( - assembleOrcadArtifact({ - templateDir: fixture.templateDir, - cacheRoot: fixture.cacheRoot, - target: TARGET, - runtimePath: fixture.runtimePath - }) - ).rejects.toThrow('target identity does not match') + await expect(assembleOrcadArtifact({ ...fixture, target: TARGET })).rejects.toThrow( + 'target identity does not match' + ) }) - it('repairs corrupt artifacts beside the old entry and reuses the repair', async () => { + it('rejects a self-consistent runtime reference to another Node', async () => { const fixture = createTemplate() - const first = await assembleOrcadArtifact({ - templateDir: fixture.templateDir, - cacheRoot: fixture.cacheRoot, - target: TARGET, - runtimePath: fixture.runtimePath + const markerPath = join( + fixture.templateDir, + ORCAD_TEMPLATE_TARGETS_DIR, + TARGET, + ORCAD_NODE_RUNTIME_MARKER_FILENAME + ) + write(markerPath, `${'0'.repeat(64)}\n`) + rewriteManifest(fixture.templateDir, (manifest) => { + manifest.targets[TARGET]!.files[ORCAD_NODE_RUNTIME_MARKER_FILENAME] = sha256(markerPath) }) + + await expect(assembleOrcadArtifact({ ...fixture, target: TARGET })).rejects.toThrow( + 'does not reference the pinned Node' + ) + }) + + it('repairs corrupt artifacts beside the old entry and reuses the repair', async () => { + const fixture = createTemplate() + const first = await assembleOrcadArtifact({ ...fixture, target: TARGET }) write(join(first, 'orcad.js'), 'corrupted-cache-entry') const repaired = await assembleOrcadArtifact({ ...fixture, target: TARGET }) @@ -241,79 +232,44 @@ describe('assembleOrcadArtifact', () => { it('rejects an optional browser without a matching manifest checksum', async () => { const fixture = createTemplate() - const manifestPath = join(fixture.templateDir, ORCAD_TEMPLATE_MANIFEST_FILENAME) - const manifest = z - .object({ targets: z.record(z.string(), z.record(z.string(), z.unknown())) }) - .passthrough() - .parse(JSON.parse(readFileSync(manifestPath, 'utf8'))) - delete manifest.targets[TARGET]?.browserSha256 - write(manifestPath, JSON.stringify(manifest)) + rewriteManifest(fixture.templateDir, (manifest) => { + delete manifest.targets[TARGET]?.browserSha256 + }) - await expect( - assembleOrcadArtifact({ - templateDir: fixture.templateDir, - cacheRoot: fixture.cacheRoot, - target: TARGET, - runtimePath: fixture.runtimePath - }) - ).rejects.toThrow('browserName and browserSha256') + await expect(assembleOrcadArtifact({ ...fixture, target: TARGET })).rejects.toThrow( + 'browserName and browserSha256' + ) }) it('rejects a manifest that omits a required common artifact checksum', async () => { const fixture = createTemplate() - const manifestPath = join(fixture.templateDir, ORCAD_TEMPLATE_MANIFEST_FILENAME) - const manifest = z - .object({ commonSha256: z.record(z.string(), z.string()) }) - .passthrough() - .parse(JSON.parse(readFileSync(manifestPath, 'utf8'))) - delete manifest.commonSha256['orcad.js'] - write(manifestPath, JSON.stringify(manifest)) + rewriteManifest(fixture.templateDir, (manifest) => { + delete manifest.commonSha256['orcad.js'] + }) - await expect( - assembleOrcadArtifact({ - templateDir: fixture.templateDir, - cacheRoot: fixture.cacheRoot, - target: TARGET, - runtimePath: fixture.runtimePath - }) - ).rejects.toThrow('manifest omits orcad.js') + await expect(assembleOrcadArtifact({ ...fixture, target: TARGET })).rejects.toThrow( + 'manifest omits orcad.js' + ) }) }) describe('materializeOrcadArtifact cancellation', () => { - it('detaches either cancelled caller without cancelling their shared cache fill', async () => { + it('detaches a cancelled caller without cancelling the shared cache fill', async () => { const fixture = createTemplate() - let complete: (path: string) => void = () => {} - vi.mocked(materializeCachedOrcadBunRuntime).mockReturnValue( - new Promise((resolve) => { - complete = resolve - }) - ) const first = new AbortController() - const second = new AbortController() const one = materializeOrcadArtifact(TARGET, { ...fixture, signal: first.signal }) - const two = materializeOrcadArtifact(TARGET, { ...fixture, signal: second.signal }) - const three = materializeOrcadArtifact(TARGET, fixture) - const firstRejected = expect(one).rejects.toThrow('first cancelled') - const secondRejected = expect(two).rejects.toThrow('second cancelled') - await vi.waitFor(() => expect(materializeCachedOrcadBunRuntime).toHaveBeenCalledOnce()) + const two = materializeOrcadArtifact(TARGET, fixture) first.abort(new Error('first cancelled')) - second.abort(new Error('second cancelled')) - await Promise.all([firstRejected, secondRejected]) - complete(fixture.runtimePath) - const artifact = await three + await expect(one).rejects.toThrow('first cancelled') + const artifact = await two expect(readFileSync(join(artifact, 'orcad.js'), 'utf8')).toBe('orcad-entry') - expect(materializeCachedOrcadBunRuntime).toHaveBeenCalledWith(TARGET, fixture.cacheRoot, { - fetcher: undefined - }) }) - it('refuses an already cancelled request before reading or fetching artifacts', async () => { + it('refuses an already cancelled request before reading artifacts', async () => { const controller = new AbortController() controller.abort(new Error('cancelled')) await expect(materializeOrcadArtifact(TARGET, { signal: controller.signal })).rejects.toThrow( 'cancelled' ) - expect(materializeCachedOrcadBunRuntime).not.toHaveBeenCalled() }) }) diff --git a/src/main/ssh/orcad-artifact-materializer.ts b/src/main/ssh/orcad-artifact-materializer.ts index 2efe902b5042..55decfabd77b 100644 --- a/src/main/ssh/orcad-artifact-materializer.ts +++ b/src/main/ssh/orcad-artifact-materializer.ts @@ -6,57 +6,53 @@ import { z } from 'zod' import { getAppEnvironment } from '../../shared/app-environment' import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import { - ORCAD_BUILD_TARGET_FILENAME, - orcadBunRuntimeFilename, - orcadArtifactHashPrefix, + ORCAD_NODE_RUNTIME_MARKER_FILENAME, + ORCAD_SERVER_TARGET_FILENAME, ORCAD_TEMPLATE_MANIFEST_FILENAME, ORCAD_TEMPLATE_TARGETS_DIR, ORCAD_VERSION, ORCAD_VERSION_FILENAME, - ORCAD_RIPGREP_ARTIFACTS, orcadArtifactFilenames, - orcadTemplateCommonFilenames + orcadTemplateCommonFilenames, + orcadTemplateTargetFilenames } from '../../shared/orcad-artifacts' -import type { OrcadBunTarget } from '../../shared/orcad-bun-runtime' +import { NODE_RUNTIME_ASSETS, type ServerTarget } from '../../shared/node-runtime-pin' import { findOrcadCachePath } from './orcad-cache-path' import { fileSha256, - materializeCachedOrcadBunRuntime, verifyFileSha256, - type OrcadBunRuntimeMaterializeOptions -} from './orcad-bun-runtime-materializer' + type PinnedRuntimeMaterializeOptions +} from './pinned-runtime-materializer' +const Sha256Schema = z.string().regex(/^[a-f0-9]{64}$/u) const TemplateTargetSchema = z .object({ - targetSha256: z.string().regex(/^[a-f0-9]{64}$/u), - watcherSha256: z.string().regex(/^[a-f0-9]{64}$/u), + files: z.record(z.string(), Sha256Schema), browserName: z .string() .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u) .optional(), - browserSha256: z - .string() - .regex(/^[a-f0-9]{64}$/u) - .optional() + browserSha256: Sha256Schema.optional() }) .refine((target) => Boolean(target.browserName) === Boolean(target.browserSha256), { message: 'browserName and browserSha256 must either both be present or both be absent' }) const TemplateManifestSchema = z.object({ - schemaVersion: z.literal(2), - commonSha256: z.record(z.string(), z.string().regex(/^[a-f0-9]{64}$/u)), + schemaVersion: z.literal(3), + commonSha256: z.record(z.string(), Sha256Schema), targets: z.record(z.string(), TemplateTargetSchema) }) -type MaterializeOptions = OrcadBunRuntimeMaterializeOptions & { +type MaterializeOptions = PinnedRuntimeMaterializeOptions & { templateDir?: string cacheRoot?: string } const materializations = new Map>() +/** A verified slot directory for `target`; its runtime is referenced, not included (design D2). */ export async function materializeOrcadArtifact( - target: OrcadBunTarget, + target: ServerTarget, options: MaterializeOptions = {} ): Promise { options.signal?.throwIfAborted() @@ -69,36 +65,23 @@ export async function materializeOrcadArtifact( return waitForPromiseWithSignal(existing, options.signal) } // Cancellation detaches one caller; the bounded cache fill still serves other deployments. - const pending = materializeOrcadArtifactInner(target, templateDir, cacheRoot, { - fetcher: options.fetcher - }).finally(() => materializations.delete(key)) + const pending = assembleOrcadArtifact({ templateDir, cacheRoot, target }).finally(() => + materializations.delete(key) + ) materializations.set(key, pending) return waitForPromiseWithSignal(pending, options.signal) } -async function materializeOrcadArtifactInner( - target: OrcadBunTarget, - templateDir: string, - cacheRoot: string, - options: MaterializeOptions -): Promise { - const manifest = await readTemplateManifest(templateDir) - await verifyTemplate(templateDir, target, manifest) - const runtimePath = await materializeCachedOrcadBunRuntime(target, cacheRoot, options) - return await assembleOrcadArtifact({ templateDir, cacheRoot, target, runtimePath, manifest }) -} - export async function assembleOrcadArtifact(args: { templateDir: string cacheRoot: string - target: OrcadBunTarget - runtimePath: string + target: ServerTarget manifest?: z.infer }): Promise { const manifest = args.manifest ?? (await readTemplateManifest(args.templateDir)) await verifyTemplate(args.templateDir, args.target, manifest) - const sources = artifactSources(args.templateDir, args.target, args.runtimePath, manifest) - const { fullVersion, sourceHashes } = await computeArtifactIdentity(sources, args.target) + const sources = artifactSources(args.templateDir, args.target, manifest) + const { fullVersion, sourceHashes } = await computeArtifactIdentity(sources) const targetRoot = join(args.cacheRoot, args.target) const cached = await findOrcadCachePath( (attempt) => join(targetRoot, `${fullVersion}${attempt ? `.repair-${attempt}` : ''}`), @@ -138,10 +121,13 @@ export async function assembleOrcadArtifact(args: { } } +function isExecutableArtifact(filename: string): boolean { + return /(?:^|\/)(?:rg|spawn-helper)$/.test(filename) +} + function artifactSources( templateDir: string, - target: OrcadBunTarget, - runtimePath: string, + target: ServerTarget, manifest: z.infer ): { filename: string; path: string; executable?: boolean }[] { const targetDir = join(templateDir, ORCAD_TEMPLATE_TARGETS_DIR, target) @@ -149,19 +135,11 @@ function artifactSources( if (!targetManifest) { throw new Error(`Packaged orcad template does not support ${target}`) } + const targetFiles = new Set(orcadTemplateTargetFilenames(target)) const required = orcadArtifactFilenames(target).map((filename) => ({ filename, - path: - filename === orcadBunRuntimeFilename(target) - ? runtimePath - : filename === ORCAD_BUILD_TARGET_FILENAME - ? join(targetDir, ORCAD_BUILD_TARGET_FILENAME) - : filename.endsWith('watcher.node') - ? join(targetDir, 'watcher.node') - : join(templateDir, filename), - executable: - filename === orcadBunRuntimeFilename(target) || - ORCAD_RIPGREP_ARTIFACTS.some((artifact) => artifact === filename && artifact.endsWith('/rg')) + path: join(targetFiles.has(filename) ? targetDir : templateDir, ...filename.split('/')), + executable: isExecutableArtifact(filename) })) if (!targetManifest.browserName) { return required @@ -177,10 +155,9 @@ function artifactSources( } async function computeArtifactIdentity( - sources: { filename: string; path: string }[], - target: OrcadBunTarget + sources: { filename: string; path: string }[] ): Promise<{ fullVersion: string; sourceHashes: Map }> { - const hash = createHash('sha256').update(orcadArtifactHashPrefix(target)) + const hash = createHash('sha256') const sourceHashes = new Map() for (const source of sources) { const sourceHash = createHash('sha256') @@ -227,15 +204,14 @@ async function readTemplateManifest( async function verifyTemplate( templateDir: string, - target: OrcadBunTarget, + target: ServerTarget, manifest: z.infer ): Promise { const targetManifest = manifest.targets[target] if (!targetManifest) { throw new Error(`Packaged orcad template does not support ${target}`) } - const commonFilenames = orcadTemplateCommonFilenames() - for (const filename of commonFilenames) { + for (const filename of orcadTemplateCommonFilenames()) { const expected = manifest.commonSha256[filename] if (!expected) { throw new Error(`Packaged orcad template manifest omits ${filename}`) @@ -243,16 +219,24 @@ async function verifyTemplate( await verifyFileSha256(join(templateDir, filename), expected, `orcad template ${filename}`) } const targetDir = join(templateDir, ORCAD_TEMPLATE_TARGETS_DIR, target) - const targetIdentityPath = join(targetDir, ORCAD_BUILD_TARGET_FILENAME) - await verifyFileSha256(targetIdentityPath, targetManifest.targetSha256, `${target} build target`) - if ((await readFile(targetIdentityPath, 'utf8')).trim() !== target) { + for (const filename of orcadTemplateTargetFilenames(target)) { + const expected = targetManifest.files[filename] + if (!expected) { + throw new Error(`Packaged orcad template manifest omits ${target} ${filename}`) + } + await verifyFileSha256(join(targetDir, filename), expected, `${target} ${filename}`) + } + if ((await readFile(join(targetDir, ORCAD_SERVER_TARGET_FILENAME), 'utf8')).trim() !== target) { throw new Error(`Packaged orcad template target identity does not match ${target}`) } - await verifyFileSha256( - join(targetDir, 'watcher.node'), - targetManifest.watcherSha256, - `${target} watcher` + // Why against this client's pin: the slot must name the runtime this client will upload. + const runtimeReference = await readFile( + join(targetDir, ORCAD_NODE_RUNTIME_MARKER_FILENAME), + 'utf8' ) + if (runtimeReference.trim() !== NODE_RUNTIME_ASSETS[target].executableSha256) { + throw new Error(`Packaged orcad template ${target} does not reference the pinned Node`) + } if (targetManifest.browserName && targetManifest.browserSha256) { await verifyFileSha256( join(targetDir, targetManifest.browserName), diff --git a/src/main/ssh/orcad-bun-runtime-materializer.ts b/src/main/ssh/orcad-bun-runtime-materializer.ts deleted file mode 100644 index e52a87534eb1..000000000000 --- a/src/main/ssh/orcad-bun-runtime-materializer.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { createHash, randomUUID } from 'node:crypto' -import { createReadStream, readdirSync } from 'node:fs' -import { chmod, link, mkdir, open, rm } from 'node:fs/promises' -import { basename, join } from 'node:path' -import { runProcess, type ProcessResult } from '../../shared/child-process/run-process' -import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' -import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' -import { getZipExtractorCommand } from '../../shared/zip-extractor-command' -import { getMainHttpClient, type MainHttpClient } from '../network/http-client' -import { findOrcadCachePath } from './orcad-cache-path' -import { orcadBunRuntimeFilename } from '../../shared/orcad-artifacts' -import { - ORCAD_BUN_RELEASE_ASSETS, - ORCAD_BUN_VERSION, - orcadBunReleaseUrl, - type OrcadBunTarget -} from '../../shared/orcad-bun-runtime' - -const MAX_BUN_ARCHIVE_BYTES = 200 * 1024 * 1024 - -export type OrcadBunRuntimeMaterializeOptions = { - fetcher?: MainHttpClient['fetch'] - signal?: AbortSignal -} - -export async function materializeCachedOrcadBunRuntime( - target: OrcadBunTarget, - cacheRoot: string, - options: OrcadBunRuntimeMaterializeOptions -): Promise { - options.signal?.throwIfAborted() - const asset = ORCAD_BUN_RELEASE_ASSETS[target] - const runtimeDir = join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, target) - await mkdir(runtimeDir, { recursive: true }) - const runtime = await findOrcadCachePath( - (attempt) => - join(runtimeDir, `${attempt ? `repair-${attempt}-` : ''}${orcadBunRuntimeFilename(target)}`), - async (path) => (await fileSha256(path)) === asset.executableSha256 - ) - const runtimePath = runtime.path - if (runtime.verified) { - if (!target.startsWith('win32-')) { - await chmod(runtimePath, 0o755) - } - return runtimePath - } - const temporaryDir = join(runtimeDir, `.download-${process.pid}-${randomUUID()}`) - await mkdir(temporaryDir, { recursive: true }) - try { - const archivePath = join(temporaryDir, basename(asset.filename)) - await downloadVerifiedArchive( - orcadBunReleaseUrl(asset), - archivePath, - asset.sha256, - options.fetcher ?? getMainHttpClient().fetch, - options.signal - ) - options.signal?.throwIfAborted() - const extractedDir = join(temporaryDir, 'extracted') - await mkdir(extractedDir) - const result = await extractArchive(archivePath, extractedDir, options.signal) - options.signal?.throwIfAborted() - if (result.code !== 0) { - throw new Error(`Bun archive extraction failed: ${result.stderr || result.stdout}`) - } - const executable = findExtractedBun(extractedDir, target) - await verifyFileSha256(executable, asset.executableSha256, `${target} Bun executable`) - if (!target.startsWith('win32-')) { - await chmod(executable, 0o755) - } - options.signal?.throwIfAborted() - try { - await link(executable, runtimePath) - } catch (error) { - if ((await fileSha256(runtimePath)) !== asset.executableSha256) { - throw new Error(`Bun runtime cache entry is unavailable or corrupted: ${runtimePath}`, { - cause: error - }) - } - } - await verifyFileSha256(runtimePath, asset.executableSha256, `${target} cached Bun executable`) - return runtimePath - } finally { - await rm(temporaryDir, { recursive: true, force: true }) - } -} - -/** - * Why the extractor needs a message of its own: `unzip` is absent from a minimal POSIX install, - * and a bare `spawn unzip ENOENT` names neither the missing tool nor the override. A misconfigured - * `ORCA_UNZIP_BIN` whose parent is a file reports ENOTDIR instead, which is the same verdict. - * - * The errno is the program path's, not the caller's: `runProcess` leaves cwd unset, so the child - * inherits the parent's without resolving it. Measured on macOS, Linux and Windows — spawn still - * succeeds from a deleted cwd, even though `process.cwd()` itself throws ENOENT there. - */ -async function extractArchive( - archivePath: string, - extractDir: string, - signal?: AbortSignal -): Promise { - const command = getZipExtractorCommand(archivePath, extractDir) - try { - return await runProcess({ - program: command.file, - args: command.args, - timeoutMs: 120_000, - signal - }) - } catch (error) { - if (isDefinitiveAbsence(error)) { - throw new Error( - `Bun archive extraction could not run ${command.file}: install ${command.label}, ` + - 'or set ORCA_UNZIP_BIN to an unzip-compatible extractor.', - { cause: error } - ) - } - throw error - } -} - -async function downloadVerifiedArchive( - url: string, - destination: string, - expectedSha256: string, - fetcher: MainHttpClient['fetch'], - signal?: AbortSignal -): Promise { - const stall = new AbortController() - const downloadSignal = signal ? AbortSignal.any([signal, stall.signal]) : stall.signal - const stallTimer = setTimeout(() => stall.abort(new Error('Bun download stalled')), 120_000) - try { - const response = await fetcher(url, { redirect: 'follow', signal: downloadSignal }) - if (!response.ok || !response.body) { - await response.body?.cancel().catch(() => undefined) - throw new Error(`Bun download failed: ${response.status} ${response.statusText}`) - } - const declaredLength = Number(response.headers.get('content-length')) - if (Number.isFinite(declaredLength) && declaredLength > MAX_BUN_ARCHIVE_BYTES) { - await response.body.cancel().catch(() => undefined) - throw new Error('Bun download exceeded the archive size limit') - } - const handle = await open(destination, 'wx', 0o600).catch(async (error) => { - await response.body?.cancel().catch(() => undefined) - throw error - }) - const reader = response.body.getReader() - const hash = createHash('sha256') - let total = 0 - try { - for (;;) { - downloadSignal.throwIfAborted() - const chunk = await waitForPromiseWithSignal(reader.read(), downloadSignal) - if (chunk.done) { - break - } - if (chunk.value.byteLength > 0) { - stallTimer.refresh() - } - total += chunk.value.byteLength - if (total > MAX_BUN_ARCHIVE_BYTES) { - throw new Error('Bun download exceeded the archive size limit') - } - hash.update(chunk.value) - await writeAll(handle, chunk.value) - } - } catch (error) { - await reader.cancel().catch(() => undefined) - throw error - } finally { - await handle.close() - } - const actual = hash.digest('hex') - if (actual !== expectedSha256) { - throw new Error(`Bun archive checksum mismatch: expected ${expectedSha256}, got ${actual}`) - } - } finally { - clearTimeout(stallTimer) - } -} - -async function writeAll( - handle: Awaited>, - bytes: Uint8Array -): Promise { - let offset = 0 - while (offset < bytes.byteLength) { - const { bytesWritten } = await handle.write(bytes, offset, bytes.byteLength - offset) - if (bytesWritten === 0) { - throw new Error('Bun archive write made no progress') - } - offset += bytesWritten - } -} - -function findExtractedBun(root: string, target: OrcadBunTarget): string { - const expected = target.startsWith('win32-') ? 'bun.exe' : 'bun' - const entry = readdirSync(root, { recursive: true, withFileTypes: true }).find( - (candidate) => candidate.isFile() && candidate.name === expected - ) - if (!entry) { - throw new Error(`Downloaded Bun archive contained no ${expected}`) - } - return join(entry.parentPath, entry.name) -} - -export async function verifyFileSha256( - path: string, - expected: string, - label: string -): Promise { - const actual = await fileSha256(path) - if (actual !== expected) { - throw new Error(`${label} checksum mismatch: expected ${expected}, got ${actual ?? 'missing'}`) - } -} - -export async function fileSha256(path: string): Promise { - try { - const hash = createHash('sha256') - for await (const chunk of createReadStream(path)) { - hash.update(chunk) - } - return hash.digest('hex') - } catch { - return null - } -} diff --git a/src/main/ssh/orcad-deployment-target.test.ts b/src/main/ssh/orcad-deployment-target.test.ts index db5915e659c0..2293078ae33a 100644 --- a/src/main/ssh/orcad-deployment-target.test.ts +++ b/src/main/ssh/orcad-deployment-target.test.ts @@ -1,5 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { parseOrcadLinuxLibc, resolveOrcadDeploymentTarget } from './orcad-deployment-target' +import { + parseGlibcVersion, + parseOrcadLinuxLibc, + resolveOrcadDeploymentTarget, + resolveOrcadDeploymentTargetFacts +} from './orcad-deployment-target' import { SshConnection } from './ssh-connection' import { createCallbacks, createTarget } from './ssh-connection-test-fixtures' import { execCommand } from './ssh-relay-deploy-helpers' @@ -75,4 +80,41 @@ describe('deployment C library selection', () => { expect(() => parseOrcadLinuxLibc(output)).toThrow('Could not identify') } ) + + it.each([ + ['ldd (Ubuntu GLIBC 2.31-0ubuntu9.16) 2.31\nCopyright (C) 2020', { major: 2, minor: 31 }], + ['ldd (GNU libc) 2.17', { major: 2, minor: 17 }], + ['glibc 2.28', { major: 2, minor: 28 }], + ['ldd (GNU libc) unknown', null] + ])('reads glibc major.minor from %j', (output, expected) => { + expect(parseGlibcVersion(output)).toEqual(expected) + }) + + it('returns the glibc version with the target, from either probe', async () => { + const conn = new SshConnection(createTarget(), createCallbacks()) + const host = getRemoteHostPlatform('linux-arm64') + vi.mocked(execCommand).mockResolvedValueOnce('ldd (Debian GLIBC 2.36-9+deb12u4) 2.36') + await expect(resolveOrcadDeploymentTargetFacts({ conn, host })).resolves.toEqual({ + target: 'linux-arm64-glibc', + glibc: { major: 2, minor: 36 } + }) + vi.mocked(execCommand) + .mockResolvedValueOnce('ldd: not found') + .mockResolvedValueOnce('glibc 2.27') + await expect(resolveOrcadDeploymentTargetFacts({ conn, host })).resolves.toEqual({ + target: 'linux-arm64-glibc', + glibc: { major: 2, minor: 27 } + }) + }) + + it('reports no glibc on musl and non-Linux hosts', async () => { + const conn = new SshConnection(createTarget(), createCallbacks()) + vi.mocked(execCommand).mockResolvedValueOnce('musl libc (x86_64)\nVersion 1.2.5') + await expect( + resolveOrcadDeploymentTargetFacts({ conn, host: getRemoteHostPlatform('linux-x64') }) + ).resolves.toEqual({ target: 'linux-x64-musl', glibc: null }) + await expect( + resolveOrcadDeploymentTargetFacts({ conn, host: getRemoteHostPlatform('darwin-arm64') }) + ).resolves.toEqual({ target: 'darwin-arm64', glibc: null }) + }) }) diff --git a/src/main/ssh/orcad-deployment-target.ts b/src/main/ssh/orcad-deployment-target.ts index 9fd98fa8cfbd..c0207e7e4588 100644 --- a/src/main/ssh/orcad-deployment-target.ts +++ b/src/main/ssh/orcad-deployment-target.ts @@ -1,8 +1,19 @@ -import type { OrcadBunTarget } from '../../shared/orcad-bun-runtime' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { SERVER_TARGETS, type ServerTarget } from '../../shared/node-runtime-pin' +import { ORCAD_SERVER_TARGET_FILENAME } from '../../shared/orcad-artifacts' import type { SshConnection } from './ssh-connection' import { execCommand } from './ssh-relay-deploy-helpers' import type { RemoteHostPlatform } from './ssh-remote-platform' +/** The host answered the libc probe, but with nothing this client recognises. */ +export class UnidentifiedHostLibcError extends Error { + constructor() { + super('Could not identify the host C library for the bundled Orca runtime') + this.name = 'UnidentifiedHostLibcError' + } +} + export function parseOrcadLinuxLibc(output: string): 'glibc' | 'musl' { if (/\bmusl\b/i.test(output)) { return 'musl' @@ -10,7 +21,22 @@ export function parseOrcadLinuxLibc(output: string): 'glibc' | 'musl' { if (/\b(?:glibc|GNU libc|GNU C Library)\b/i.test(output)) { return 'glibc' } - throw new Error('Could not identify the host C library for the bundled Orca runtime') + throw new UnidentifiedHostLibcError() +} + +export type GlibcVersion = { major: number; minor: number } + +export type OrcadDeploymentTargetFacts = { + target: ServerTarget + /** The host's glibc, when the libc probe printed one; null on musl and non-Linux hosts. */ + glibc: GlibcVersion | null +} + +/** `ldd --version`'s first line ends in the version; `getconf GNU_LIBC_VERSION` prints `glibc 2.31`. */ +export function parseGlibcVersion(output: string): GlibcVersion | null { + const firstLine = output.split('\n', 1)[0] ?? '' + const match = /(\d+)\.(\d+)\s*$/.exec(firstLine.trim()) ?? /\bglibc\s+(\d+)\.(\d+)/i.exec(output) + return match ? { major: Number(match[1]), minor: Number(match[2]) } : null } export async function resolveOrcadDeploymentTarget(options: { @@ -18,22 +44,49 @@ export async function resolveOrcadDeploymentTarget(options: { host: RemoteHostPlatform signal?: AbortSignal exec?: (command: string) => Promise -}): Promise { +}): Promise { + return (await resolveOrcadDeploymentTargetFacts(options)).target +} + +export async function resolveOrcadDeploymentTargetFacts(options: { + conn: SshConnection + host: RemoteHostPlatform + signal?: AbortSignal + exec?: (command: string) => Promise +}): Promise { const { host } = options if (host.os !== 'linux') { - return `${host.os}-${host.arch}` + return { target: `${host.os}-${host.arch}`, glibc: null } } const exec = options.exec ?? ((command: string) => execCommand(options.conn, command, { signal: options.signal })) let output = await exec('ldd --version 2>&1 || true') try { - return `linux-${host.arch}-${parseOrcadLinuxLibc(output)}` + return linuxTargetFacts(host, output) } catch { output = await exec( 'getconf GNU_LIBC_VERSION 2>/dev/null || ' + 'for loader in /lib/ld-musl-*.so.1; do [ ! -e "$loader" ] || { echo musl; break; }; done' ) } - return `linux-${host.arch}-${parseOrcadLinuxLibc(output)}` + return linuxTargetFacts(host, output) +} + +function linuxTargetFacts(host: RemoteHostPlatform, output: string): OrcadDeploymentTargetFacts { + const libc = parseOrcadLinuxLibc(output) + return { + target: `linux-${host.arch}-${libc}`, + glibc: libc === 'glibc' ? parseGlibcVersion(output) : null + } +} + +/** The server target an assembled bundle was built for. */ +export function readOrcadBundleTarget(localOrcadDir: string): ServerTarget { + const recorded = readFileSync(join(localOrcadDir, ORCAD_SERVER_TARGET_FILENAME), 'utf8').trim() + const target = SERVER_TARGETS.find((candidate) => candidate === recorded) + if (!target) { + throw new Error(`The orcad bundle names no known server target: ${recorded}`) + } + return target } diff --git a/src/main/ssh/orcad-remote-deploy.test.ts b/src/main/ssh/orcad-remote-deploy.test.ts index 38b9fc951f99..8308ff52db57 100644 --- a/src/main/ssh/orcad-remote-deploy.test.ts +++ b/src/main/ssh/orcad-remote-deploy.test.ts @@ -17,6 +17,9 @@ vi.mock('./ssh-relay-install-transfers', () => ({ uploadRelayDirectory: vi.fn().mockResolvedValue(undefined), writeRelayFile: vi.fn().mockResolvedValue(undefined) })) +vi.mock('./orcad-remote-node-runtime', () => ({ + ensureRemoteOrcadNodeRuntime: vi.fn().mockResolvedValue(undefined) +})) vi.mock('./orcad-local-build-hash', () => ({ computeLocalOrcadBuildHash: () => 'abc123def4567890' })) @@ -26,6 +29,7 @@ import { acquireInstallLock } from './ssh-relay-install-lock' import { uploadRelayDirectory, writeRelayFile } from './ssh-relay-install-transfers' import { deployOrcad, type OrcadDeployOptions } from './orcad-remote-deploy' import { installOrcadBundle } from './orcad-remote-install' +import { ensureRemoteOrcadNodeRuntime } from './orcad-remote-node-runtime' import { abandonInstall, finalizeInstall, @@ -34,6 +38,7 @@ import { import { emptyOrcadActivationRecord, withActivatedVersion } from './orcad-activation-record' import { getRemoteHostPlatform } from './ssh-remote-platform' import type { SshConnection } from './ssh-connection' +import { NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin' const mockExec = vi.mocked(execCommand) const NEW_VERSION = '0.2.0+bb0100000000' @@ -118,8 +123,8 @@ function scriptHost(script: HostScript): void { JSON.stringify({ type: 'orca_profile_state_ready', nonce: text.match(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/)?.[0], - runtime: 'bun', - runtimeVersion: '1.4.2', + runtime: 'node', + runtimeVersion: NODE_RUNTIME_PIN.version, sqliteVersion: '3.51.0', artifactVersion: NEW_VERSION, revision: 1 @@ -152,6 +157,7 @@ function options(overrides: Partial = {}): OrcadDeployOption host: getRemoteHostPlatform('linux-x64'), remoteHome: '/home/u', localOrcadDir: '/local/out/orcad', + target: 'linux-x64-glibc', nodePath: '/usr/bin/node', userDataDir: '/home/u/.orca', bindHost: '127.0.0.1', @@ -172,7 +178,12 @@ describe('orcad install lock ownership', () => { const remoteDir = `/home/u/.orca-remote/orcad-${NEW_VERSION}` const install = (signal?: AbortSignal) => installOrcadBundle( - { ...options({ signal }), localOrcadDir: '/local/out/orcad' }, + { + ...options({ signal }), + localOrcadDir: '/local/out/orcad', + target: 'linux-x64-glibc', + nodeRuntimeArchive: async () => '/cache/node.tar.gz' + }, NEW_VERSION, remoteDir ) @@ -285,13 +296,17 @@ describe('deployOrcad', () => { expect(vi.mocked(uploadRelayDirectory).mock.calls[0][2]).toContain(`orcad-${NEW_VERSION}`) }) - it.each(['linux-arm64', 'darwin-x64'] as const)( + it.each([ + ['linux-arm64', 'linux-arm64-glibc'], + ['darwin-x64', 'darwin-x64'] + ] as const)( 'marks the %s search binary executable before completing the install', - async (platform) => { + async (platform, target) => { scriptHost({ activationRecord: '', readiness: {}, log: [] }) await deployOrcad( options({ host: getRemoteHostPlatform(platform), + target, census: { liveSessions: 1, startedSinceActivation: 0 } }) ) @@ -300,7 +315,13 @@ describe('deployOrcad', () => { ) expect(chmod).toBeGreaterThanOrEqual(0) expect(mockExec.mock.calls[chmod]?.[1]).toContain(`/ripgrep/${platform}/rg'`) - expect(mockExec.mock.calls[chmod]?.[1]).toContain("/bun-runtime'") + expect(mockExec.mock.calls[chmod]?.[1]).not.toContain('bun-runtime') + expect(String(mockExec.mock.calls[chmod]?.[1]).includes('/spawn-helper')).toBe( + platform === 'darwin-x64' + ) + expect(ensureRemoteOrcadNodeRuntime).toHaveBeenCalledWith( + expect.objectContaining({ target, slotDir: `/home/u/.orca-remote/orcad-${NEW_VERSION}` }) + ) expect(vi.mocked(uploadRelayDirectory).mock.invocationCallOrder[0]).toBeLessThan( mockExec.mock.invocationCallOrder[chmod] ) @@ -316,7 +337,9 @@ describe('deployOrcad', () => { { conn: options().conn, host: getRemoteHostPlatform('win32-x64'), - localOrcadDir: '/local/out/orcad' + localOrcadDir: '/local/out/orcad', + target: 'win32-x64', + nodeRuntimeArchive: async () => '/cache/node.zip' }, NEW_VERSION, `C:/Users/u/.orca-remote/orcad-${NEW_VERSION}` @@ -343,7 +366,7 @@ describe('deployOrcad', () => { 'restores uploaded executable modes with optional browser %s', async (browserTarget) => { const directory = mkdtempSync(join(tmpdir(), 'orcad-install-modes-')) - const binaries = ['bun-runtime', 'ripgrep/linux-x64/rg'] + const binaries = ['ripgrep/linux-x64/rg'] if (browserTarget) { binaries.push(`agent-browser-${browserTarget}`) } @@ -365,7 +388,9 @@ describe('deployOrcad', () => { { conn: options().conn, host: getRemoteHostPlatform('linux-x64'), - localOrcadDir: directory + localOrcadDir: directory, + target: 'linux-x64-musl', + nodeRuntimeArchive: async () => '/cache/node.tar.gz' }, NEW_VERSION, directory diff --git a/src/main/ssh/orcad-remote-deploy.ts b/src/main/ssh/orcad-remote-deploy.ts index f82b4014d57f..f296e08bd363 100644 --- a/src/main/ssh/orcad-remote-deploy.ts +++ b/src/main/ssh/orcad-remote-deploy.ts @@ -42,8 +42,12 @@ import { computeLocalOrcadBuildHash } from './orcad-local-build-hash' import { preflightInstalledOrcad } from './orcad-remote-preflight' import { assertPosixOrcadHost } from './orcad-remote-host-support' import { installOrcadBundle } from './orcad-remote-install' +import { join } from 'node:path' +import { getAppEnvironment } from '../../shared/app-environment' +import type { ServerTarget } from '../../shared/node-runtime-pin' import { materializeOrcadArtifact } from './orcad-artifact-materializer' -import { resolveOrcadDeploymentTarget } from './orcad-deployment-target' +import { readOrcadBundleTarget, resolveOrcadDeploymentTarget } from './orcad-deployment-target' +import { materializeNodeRuntimeArchive } from './pinned-runtime-materializer' export type OrcadDeployOptions = { conn: SshConnection @@ -51,6 +55,10 @@ export type OrcadDeployOptions = { remoteHome: string /** An already assembled bundle; otherwise materialize the packaged template for this host. */ localOrcadDir?: string + /** The bundle's server target; read from `localOrcadDir` or probed when absent. */ + target?: ServerTarget + /** Where the pinned runtime archive is cached; defaults beside the orcad artifact cache. */ + runtimeCacheRoot?: string nodePath: string userDataDir: string bindHost: string @@ -199,13 +207,22 @@ async function restoreIncumbent( /** Activate on a healthy verdict; retain changed candidate state for explicit recovery. */ export async function deployOrcad(input: OrcadDeployOptions): Promise { assertPosixOrcadHost(input.host) + const target = + input.target ?? + (input.localOrcadDir + ? readOrcadBundleTarget(input.localOrcadDir) + : await resolveOrcadDeploymentTarget(input)) const options = { ...input, + target, + nodeRuntimeArchive: () => + materializeNodeRuntimeArchive( + target, + input.runtimeCacheRoot ?? join(getAppEnvironment().getPath('userData'), 'orcad-artifacts'), + { signal: input.signal } + ), localOrcadDir: - input.localOrcadDir ?? - (await materializeOrcadArtifact(await resolveOrcadDeploymentTarget(input), { - signal: input.signal - })) + input.localOrcadDir ?? (await materializeOrcadArtifact(target, { signal: input.signal })) } const now = options.now ?? ((): Date => new Date()) const fullVersion = readLocalFullVersion(options.localOrcadDir) diff --git a/src/main/ssh/orcad-remote-gc.test.ts b/src/main/ssh/orcad-remote-gc.test.ts index 9d365c631ef4..044bd974e989 100644 --- a/src/main/ssh/orcad-remote-gc.test.ts +++ b/src/main/ssh/orcad-remote-gc.test.ts @@ -15,6 +15,8 @@ vi.mock('./ssh-relay-gc-tombstone', () => ({ })) vi.mock('./ssh-relay-install-lock', () => ({ RELAY_INSTALL_LOCK_NAME: '.install-lock', + INSTALL_LOCK_STALE_MS: 20 * 60_000, + INSTALL_LOCK_STALE_SECONDS: 20 * 60, isRelayInstallLockStale: vi.fn().mockResolvedValue(false) })) @@ -45,6 +47,9 @@ function scriptHost(options: { if (command.includes('.install-lock')) { return 'OPEN' } + if (command.includes('.store-lock') && command.includes('mkdir')) { + return 'OK' + } if (command.includes('.install-complete')) { return 'COMPLETE' } @@ -201,4 +206,24 @@ describe('orcad GC', () => { expect(removed).toEqual(['orcad-0.0.9+dead']) }) + + it('collects the runtime store only when the caller names its runtime pins', async () => { + const removed: string[] = [] + scriptHost({ listing: [], removed }) + const options = { + conn, + host, + remoteHome: '/home/u', + currentDirAbsPath: '/home/u/.orca-remote/orcad-0.2.0+bb', + record: emptyOrcadActivationRecord() + } + const inventories = (): number => + mockExec.mock.calls.filter(([, command]) => String(command).includes('RUNTIME_STORE')).length + + await gcOldOrcadVersions(options) + expect(inventories()).toBe(0) + + await gcOldOrcadVersions({ ...options, nodeRuntimePins: ['a'.repeat(64)] }) + expect(inventories()).toBe(1) + }) }) diff --git a/src/main/ssh/orcad-remote-gc.ts b/src/main/ssh/orcad-remote-gc.ts index 09997dc40d35..2f1692201e41 100644 --- a/src/main/ssh/orcad-remote-gc.ts +++ b/src/main/ssh/orcad-remote-gc.ts @@ -1,13 +1,12 @@ /** - * orcad's garbage collection, and the half of §06 falsifier 1 that says who owns it. + * orcad's garbage collection, and who owns it (design D10; to be tracked in + * docs/reference/remote-server-install-model.md). * - * **Each model GCs only its own namespace, permanently.** orcad removes `orcad-/` - * directories; the relay removes `relay-/` directories; neither ever removes the other's, - * and no plan item makes one the winner. That is not a migration compromise — the two models - * serve different users on the same machine (SSH target vs paired peer), so there is no - * moment at which one of them is entitled to clean up after the other. A pass that deleted - * the sibling's tree would be reaching across the execution boundary the whole design exists - * to keep intact. + * **Each model GCs only its own namespace.** orcad removes `orcad-/` directories; the relay + * removes `relay-/` directories; neither ever removes the other's. The converged server's + * migration sweep takes over legacy directories only in the release after it has listed them + * as diagnostics, and only on an `exited` verdict. A pass that deleted the sibling's tree + * would be reaching across the execution boundary the whole design exists to keep intact. * * On top of the ownership rule, orcad pins three directories that are idle-looking but * load-bearing: the active version, the rollback target, and whichever version the LIVE @@ -25,6 +24,7 @@ import { parseOrcadLiveness } from './orcad-remote-launch' import type { RemoteHostPlatform } from './ssh-remote-platform' +import { gcRemoteNodeRuntimeStore } from './remote-node-runtime-store-gc' export type OrcadGcOptions = { conn: SshConnection @@ -41,6 +41,11 @@ export type OrcadGcOptions = { * would remove the tree under a running process. */ liveDaemonVersion?: string | null + /** + * executableSha256 of every runtime pin this client runs. Also the gate for the shared + * runtime store pass: without it this client cannot say which runtime is current. + */ + nodeRuntimePins?: readonly string[] signal?: AbortSignal } @@ -75,4 +80,11 @@ export async function gcOldOrcadVersions(options: OrcadGcOptions): Promise } } ) + // Why after the version pass: removing version dirs is what drops their runtime references. + if (options.nodeRuntimePins?.length) { + await gcRemoteNodeRuntimeStore(options.conn, options.host, options.remoteHome, { + currentPins: options.nodeRuntimePins, + signal: options.signal + }) + } } diff --git a/src/main/ssh/orcad-remote-install-termination.test.ts b/src/main/ssh/orcad-remote-install-termination.test.ts index fbc0dcb156c1..7951c3755bad 100644 --- a/src/main/ssh/orcad-remote-install-termination.test.ts +++ b/src/main/ssh/orcad-remote-install-termination.test.ts @@ -9,6 +9,9 @@ vi.mock('./ssh-relay-install-transfers', () => ({ uploadRelayDirectory: vi.fn(), writeRelayFile: vi.fn() })) +vi.mock('./orcad-remote-node-runtime', () => ({ + ensureRemoteOrcadNodeRuntime: vi.fn().mockResolvedValue(undefined) +})) import type { SshConnection } from './ssh-connection' import { execCommand } from './ssh-relay-deploy-helpers' @@ -49,7 +52,14 @@ describe.each([ ] const install = (signal?: AbortSignal): Promise => installOrcadBundle( - { conn, host, localOrcadDir: '/local/orcad', signal }, + { + conn, + host, + localOrcadDir: '/local/orcad', + target: host.os === 'win32' ? 'win32-x64' : 'linux-x64-glibc', + nodeRuntimeArchive: async () => '/cache/node-archive', + signal + }, fullVersion, remoteDir ) diff --git a/src/main/ssh/orcad-remote-install.ts b/src/main/ssh/orcad-remote-install.ts index 9866f6666438..017a8d89d85c 100644 --- a/src/main/ssh/orcad-remote-install.ts +++ b/src/main/ssh/orcad-remote-install.ts @@ -1,5 +1,7 @@ -import { orcadBunRuntimeFilename } from '../../shared/orcad-artifacts' +import { orcadNodePtyNativeArtifacts, orcadRipgrepArtifact } from '../../shared/orcad-artifacts' import { orcadAgentBrowserNativeName } from '../../shared/orcad-agent-browser-name' +import type { ServerTarget } from '../../shared/node-runtime-pin' +import { ensureRemoteOrcadNodeRuntime } from './orcad-remote-node-runtime' import { execCommand } from './ssh-relay-deploy-helpers' import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' import { shellEscape } from './ssh-connection-utils' @@ -20,11 +22,23 @@ export async function installOrcadBundle( conn: SshConnection host: RemoteHostPlatform localOrcadDir: string + target: ServerTarget + /** The locally verified pinned Node archive, fetched only when the host lacks the runtime. */ + nodeRuntimeArchive: () => Promise signal?: AbortSignal }, fullVersion: string, remoteDir: string ): Promise { + // Why before the slot probe: an installed slot is unlaunchable without the runtime it names. + await ensureRemoteOrcadNodeRuntime({ + conn: options.conn, + host: options.host, + slotDir: remoteDir, + target: options.target, + archivePath: options.nodeRuntimeArchive, + signal: options.signal + }) if ( await isRemoteInstallComplete(options.conn, ORCAD_INSTALL_MODEL, remoteDir, options.host, { signal: options.signal @@ -47,10 +61,14 @@ export async function installOrcadBundle( signal: options.signal }) if (options.host.os !== 'win32') { - await execCommand(options.conn, executablePermissionsCommand(options.host, remoteDir), { - wrapCommand: options.host.commandDialect !== 'powershell', - signal: options.signal - }) + await execCommand( + options.conn, + executablePermissionsCommand(options.host, remoteDir, options.target), + { + wrapCommand: options.host.commandDialect !== 'powershell', + signal: options.signal + } + ) } await writeRelayFile( options.conn, @@ -73,11 +91,14 @@ export async function installOrcadBundle( } } -function executablePermissionsCommand(host: RemoteHostPlatform, directory: string): string { - const required = [ - joinRemotePath(host, directory, orcadBunRuntimeFilename(host.os)), - joinRemotePath(host, directory, 'ripgrep', host.relayPlatform, 'rg') - ] +function executablePermissionsCommand( + host: RemoteHostPlatform, + directory: string, + target: ServerTarget +): string { + const required = [orcadRipgrepArtifact(target), ...orcadNodePtyNativeArtifacts(target)] + .filter((artifact) => /\/(?:rg|spawn-helper)$/.test(artifact)) + .map((artifact) => joinRemotePath(host, directory, ...artifact.split('/'))) const browsers = new Set( (['glibc', 'musl'] as const).map((libc) => shellEscape( diff --git a/src/main/ssh/orcad-remote-node-runtime-promotion-lock.test.ts b/src/main/ssh/orcad-remote-node-runtime-promotion-lock.test.ts new file mode 100644 index 000000000000..0e2fb9f9a140 --- /dev/null +++ b/src/main/ssh/orcad-remote-node-runtime-promotion-lock.test.ts @@ -0,0 +1,98 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshConnection } from './ssh-connection' +import { + ensureRemoteOrcadNodeRuntime, + REMOTE_NODE_RUNTIME_MISSING, + REMOTE_NODE_RUNTIME_READY +} from './orcad-remote-node-runtime' +import { RUNTIME_STORE_LOCK_NAME } from './remote-node-runtime-store-lock' +import { execCommand } from './ssh-relay-deploy-helpers' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn() })) +vi.mock('./ssh-relay-install-transfers', () => ({ uploadRelayDirectory: vi.fn(async () => {}) })) + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every remote call goes through the mocked execCommand. +const conn = {} as SshConnection +const host = getRemoteHostPlatform('linux-x64') +const store = '/home/u/.orca-remote/runtimes' +const lock = `${store}/${RUNTIME_STORE_LOCK_NAME}` +let local: string +let commands: string[] + +beforeEach(() => { + local = mkdtempSync(join(tmpdir(), 'runtime-promotion-')) + writeFileSync(join(local, 'node.tar.gz'), 'archive') + commands = [] +}) +afterEach(() => { + rmSync(local, { recursive: true, force: true }) +}) + +function answer(probe: (count: number) => string): void { + let probes = 0 + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + commands.push(command) + if (command.includes(`mkdir '${lock}' 2>/dev/null`)) { + return 'OK' + } + if (command.includes('tar -xzf')) { + return REMOTE_NODE_RUNTIME_READY + } + if (command.includes(REMOTE_NODE_RUNTIME_MISSING)) { + return probe(++probes) + } + return '' + }) +} + +async function ensure(): Promise { + await ensureRemoteOrcadNodeRuntime({ + conn, + host, + slotDir: '/home/u/.orca-remote/relay-0.1.0+abc', + target: 'linux-x64-glibc', + archivePath: async () => join(local, 'node.tar.gz') + }) +} + +const indexOf = (needle: string): number => commands.findIndex((c) => c.includes(needle)) +const lastIndexOf = (needle: string): number => commands.findLastIndex((c) => c.includes(needle)) + +describe('ensureRemoteOrcadNodeRuntime promotion lock', () => { + it('promotes between taking and releasing the store lock', async () => { + answer(() => REMOTE_NODE_RUNTIME_MISSING) + await ensure() + const taken = indexOf(`mkdir '${lock}' 2>/dev/null`) + const promoted = indexOf('tar -xzf') + const released = lastIndexOf(`rm -rf '${lock}'`) + expect(taken).toBeGreaterThan(-1) + expect(promoted).toBeGreaterThan(taken) + expect(released).toBeGreaterThan(promoted) + }) + + it('skips promotion when a sibling published the pin while this client uploaded', async () => { + answer((count) => (count === 1 ? REMOTE_NODE_RUNTIME_MISSING : REMOTE_NODE_RUNTIME_READY)) + await ensure() + expect(indexOf('tar -xzf')).toBe(-1) + expect(lastIndexOf(`rm -rf '${lock}'`)).toBeGreaterThan(indexOf(`mkdir '${lock}' 2>/dev/null`)) + }) + + it('keeps the lock when promotion ends in an unconfirmed termination', async () => { + answer(() => REMOTE_NODE_RUNTIME_MISSING) + const lost = Object.assign(new Error('lost'), { sshChannelCloseConfirmed: false }) + const base = vi.mocked(execCommand).getMockImplementation() + vi.mocked(execCommand).mockImplementation(async (c, command, options) => { + if (command.includes('tar -xzf')) { + commands.push(command) + throw lost + } + return base!(c, command, options) + }) + await expect(ensure()).rejects.toBe(lost) + expect(indexOf(`rm -rf '${lock}'`)).toBe(-1) + }) +}) diff --git a/src/main/ssh/orcad-remote-node-runtime-report.ts b/src/main/ssh/orcad-remote-node-runtime-report.ts new file mode 100644 index 000000000000..5fb05424ee75 --- /dev/null +++ b/src/main/ssh/orcad-remote-node-runtime-report.ts @@ -0,0 +1,70 @@ +/** What the host-side runtime-store scripts print, shared by the POSIX and Windows installers. */ + +export const REMOTE_NODE_RUNTIME_READY = 'ORCA_NODE_RUNTIME_READY' +export const REMOTE_NODE_RUNTIME_MISSING = 'ORCA_NODE_RUNTIME_MISSING' +export const REMOTE_NODE_RUNTIME_SELFTEST_FAILED = 'ORCA_NODE_RUNTIME_SELFTEST_FAILED' +/** A file Orca wrote and verified changed or vanished afterwards; the rest of the line says which. */ +export const REMOTE_NODE_RUNTIME_SECURITY_MODIFIED = 'ORCA_NODE_RUNTIME_SECURITY_MODIFIED' +export const REMOTE_NODE_RUNTIME_EXIT_PREFIX = 'ORCA_RUNTIME_EXIT=' +export const REMOTE_NODE_RUNTIME_VERIFIED_MARKER = '.verified' + +/** The pinned runtime ran on the host and did not report its version: a host verdict, with evidence. */ +export class RemoteNodeRuntimeSelfTestError extends Error { + constructor( + readonly exitStatus: number | null, + readonly output: string + ) { + super( + `The pinned Node runtime did not run on the host (exit ${exitStatus ?? 'unknown'}): ${output}` + ) + this.name = 'RemoteNodeRuntimeSelfTestError' + } +} + +/** The host answered, and what it answered is that something rewrote or removed our verified bytes. */ +export class RemoteNodeRuntimeSecurityModifiedError extends Error { + constructor(readonly detail: string) { + super(`Security software on the host removed or modified the pinned Node runtime: ${detail}`) + this.name = 'RemoteNodeRuntimeSecurityModifiedError' + } +} + +/** Splits `ORCA_RUNTIME_EXIT=` from the output that follows it. */ +export function parseRemoteRuntimeExitReport(text: string): { + exitStatus: number | null + output: string +} { + const lines = text.split(/\r?\n/) + const index = lines.findIndex((line) => line.startsWith(REMOTE_NODE_RUNTIME_EXIT_PREFIX)) + if (index === -1) { + return { exitStatus: null, output: text.trim() } + } + const status = Number.parseInt(lines[index].slice(REMOTE_NODE_RUNTIME_EXIT_PREFIX.length), 10) + return { + exitStatus: Number.isNaN(status) ? null : status, + output: lines + .slice(index + 1) + .join('\n') + .trim() + } +} + +/** Throws the host's verdict when a promote script reported one instead of READY. */ +export function assertRemoteNodeRuntimePromoted(promoted: string): void { + const selfTestFailure = promoted.indexOf(REMOTE_NODE_RUNTIME_SELFTEST_FAILED) + if (selfTestFailure !== -1) { + const report = parseRemoteRuntimeExitReport(promoted.slice(selfTestFailure)) + throw new RemoteNodeRuntimeSelfTestError(report.exitStatus, report.output) + } + const modified = promoted + .split(/\r?\n/) + .find((line) => line.startsWith(REMOTE_NODE_RUNTIME_SECURITY_MODIFIED)) + if (modified !== undefined) { + throw new RemoteNodeRuntimeSecurityModifiedError( + modified.slice(REMOTE_NODE_RUNTIME_SECURITY_MODIFIED.length).trim() + ) + } + if (promoted.trim().split(/\r?\n/).at(-1)?.trim() !== REMOTE_NODE_RUNTIME_READY) { + throw new Error(`The host did not verify the pinned Node runtime: ${promoted.trim()}`) + } +} diff --git a/src/main/ssh/orcad-remote-node-runtime-windows.test.ts b/src/main/ssh/orcad-remote-node-runtime-windows.test.ts new file mode 100644 index 000000000000..58072790d73b --- /dev/null +++ b/src/main/ssh/orcad-remote-node-runtime-windows.test.ts @@ -0,0 +1,247 @@ +import path from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { NODE_RUNTIME_ASSETS, NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin' +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { uploadRelayDirectory } from './ssh-relay-install-transfers' +import { + ensureRemoteOrcadNodeRuntime, + probeRemoteNodeRuntimeCommand, + remoteNodeRuntimeDir, + remoteNodeRuntimePresentCommand, + REMOTE_NODE_RUNTIME_MISSING, + REMOTE_NODE_RUNTIME_READY, + RemoteNodeRuntimeSecurityModifiedError, + RemoteNodeRuntimeSelfTestError +} from './orcad-remote-node-runtime' +import { + windowsNodeRuntimeProbeCommand, + windowsNodeRuntimePromoteCommand, + WINDOWS_NODE_RUNTIME_PROMOTE_TIMEOUT_MS +} from './orcad-remote-node-runtime-windows' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { decodeRemotePowerShellScript } from './ssh-remote-powershell' + +vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn() })) +vi.mock('./ssh-relay-install-transfers', () => ({ uploadRelayDirectory: vi.fn() })) + +const host = getRemoteHostPlatform('win32-x64') +const target = 'win32-x64' +const asset = NODE_RUNTIME_ASSETS[target] +const relayDir = 'C:/Users/u/.orca-remote/relay-0.1.0+abcdef012345' +const runtimeDir = remoteNodeRuntimeDir(host, relayDir, target) +const stageDir = `C:/Users/u/.orca-remote/runtimes/.stage-node-${asset.executableSha256}-0011` +const conn = {} as SshConnection + +function promoteScript(): string { + return decodeRemotePowerShellScript( + windowsNodeRuntimePromoteCommand({ stageDir, archive: asset.archive, runtimeDir, target }) + ) +} + +beforeEach(() => { + vi.mocked(execCommand).mockReset() + vi.mocked(uploadRelayDirectory).mockReset().mockResolvedValue(undefined) +}) + +describe('Windows runtime store layout', () => { + it('sits under %USERPROFILE%\\.orca-remote\\runtimes beside the relay dirs', () => { + expect(runtimeDir).toBe(`C:/Users/u/.orca-remote/runtimes/node-${asset.executableSha256}`) + expect(path.win32.normalize(runtimeDir)).toBe( + `C:\\Users\\u\\.orca-remote\\runtimes\\node-${asset.executableSha256}` + ) + }) +}) + +describe('Windows runtime store commands', () => { + it('stays inside the EDR posture: one existing encoded powershell.exe line, no policy switch, no compilation', () => { + const commands = [ + windowsNodeRuntimeProbeCommand(runtimeDir, target, stageDir), + remoteNodeRuntimePresentCommand(host, runtimeDir), + windowsNodeRuntimePromoteCommand({ stageDir, archive: asset.archive, runtimeDir, target }) + ] + for (const command of commands) { + expect(command).toMatch(/^powershell\.exe -NoProfile -NonInteractive -EncodedCommand \S+$/) + const script = decodeRemotePowerShellScript(command) + expect(script).not.toMatch(/ExecutionPolicy|Add-Type|\.ps1|Import-Module/) + expect(script).not.toContain('bun') + } + }) + + it('probes by hash under the marker and stages the upload in the same invocation', () => { + expect(decodeRemotePowerShellScript(probeRemoteNodeRuntimeCommand(host, runtimeDir, target))) + .toMatchInlineSnapshot(` + "$ProgressPreference = 'SilentlyContinue' + function Get-OrcaSha256([string]$p) { if (Test-Path -LiteralPath $p -PathType Leaf) { (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash.ToLowerInvariant() } else { "" } } + $runtimeDir = 'C:/Users/u/.orca-remote/runtimes/node-ba4e6d110e8c1592a1ecd390f6b05f3da124b13871a5be62b341a07a853c6c32' + $exe = 'C:/Users/u/.orca-remote/runtimes/node-ba4e6d110e8c1592a1ecd390f6b05f3da124b13871a5be62b341a07a853c6c32/node.exe' + $verified = 'C:/Users/u/.orca-remote/runtimes/node-ba4e6d110e8c1592a1ecd390f6b05f3da124b13871a5be62b341a07a853c6c32/.verified' + if ((Test-Path -LiteralPath $verified -PathType Leaf) -and ((Get-OrcaSha256 $exe) -eq 'ba4e6d110e8c1592a1ecd390f6b05f3da124b13871a5be62b341a07a853c6c32')) { Write-Output 'ORCA_NODE_RUNTIME_READY'; exit 0 } + Write-Output 'ORCA_NODE_RUNTIME_MISSING'" + `) + expect( + decodeRemotePowerShellScript(windowsNodeRuntimeProbeCommand(runtimeDir, target, stageDir)) + ).toContain(`New-Item -ItemType Directory -Force -Path '${stageDir}' -ErrorAction Stop`) + }) + + it('checks only the marker and node.exe on the warm path', () => { + const script = decodeRemotePowerShellScript(remoteNodeRuntimePresentCommand(host, runtimeDir)) + expect(script).not.toContain('Get-FileHash') + expect(script).toContain(`${runtimeDir}/node.exe`) + }) + + it('verifies the zip, extracts node.exe with System32 tar.exe and falls back to Expand-Archive', () => { + const script = promoteScript() + expect(script).toContain(`-ne '${asset.archiveSha256}'`) + expect(script).toContain("Join-Path $env:SystemRoot 'System32\\tar.exe'") + expect(script).toContain( + `& $tar -xf $archive -C $stage 'node-v${NODE_RUNTIME_PIN.version}-win-x64/node.exe'` + ) + expect(script.indexOf('& $tar')).toBeLessThan(script.indexOf('Expand-Archive')) + expect(script).toContain( + `$extracted = '${stageDir}/node-v${NODE_RUNTIME_PIN.version}-win-x64/node.exe'` + ) + expect(script).toContain(`$pin = '${asset.executableSha256}'`) + }) + + it('runs node.exe before publishing, and publishes node.exe plus .verified with one directory rename', () => { + const script = promoteScript() + const ran = script.indexOf('& $extracted --version') + const moved = script.indexOf('[IO.Directory]::Move($candidate, $runtimeDir)') + expect(ran).toBeGreaterThan(0) + expect(moved).toBeGreaterThan(ran) + expect(script).toContain(`-ne 'v${NODE_RUNTIME_PIN.version}'`) + expect(script.indexOf("(Join-Path $candidate '.verified')")).toBeLessThan(moved) + expect(script).toContain("(Join-Path $candidate 'node.exe')") + }) + + it('reports bytes that change after they were verified as security software, at every step', () => { + const script = promoteScript() + for (const step of [ + 'the uploaded archive changed after it was written', + 'node.exe vanished after extraction', + 'node.exe changed after extraction', + 'node.exe changed after it ran', + 'node.exe changed after it was published' + ]) { + expect(script).toContain(`Write-Output 'ORCA_NODE_RUNTIME_SECURITY_MODIFIED ${step}'`) + } + // A post-publish change must not leave a marker the warm path would trust. + expect(script).toMatch( + /Remove-Item -LiteralPath \$verified -Force -ErrorAction SilentlyContinue; Write-Output 'ORCA_NODE_RUNTIME_SECURITY_MODIFIED node\.exe changed after it was published'/ + ) + }) + + it('removes the stage after every outcome without exiting past the cleanup', () => { + const script = promoteScript() + const body = script.slice( + script.indexOf('function Invoke-OrcaPromote'), + script.indexOf('try { Invoke-OrcaPromote }') + ) + expect(body).not.toMatch(/\bexit\b/) + expect(script.trimEnd().split('\n').slice(-2)).toEqual([ + 'Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue', + 'exit $script:code' + ]) + }) + + it('fits the command-line budget sshd hands to cmd.exe', () => { + expect( + windowsNodeRuntimePromoteCommand({ stageDir, archive: asset.archive, runtimeDir, target }) + .length + ).toBeLessThanOrEqual(8_000) + }) +}) + +describe('ensureRemoteOrcadNodeRuntime on Windows', () => { + const archivePath = async (): Promise => { + const { mkdtemp, writeFile } = await import('node:fs/promises') + const { tmpdir } = await import('node:os') + const dir = await mkdtemp(path.join(tmpdir(), 'win-runtime-archive-')) + const file = path.join(dir, asset.archive) + await writeFile(file, 'zip bytes') + return file + } + + it('returns after one unwrapped probe when the runtime is verified', async () => { + vi.mocked(execCommand).mockResolvedValueOnce(`${REMOTE_NODE_RUNTIME_READY}\r\n`) + await ensureRemoteOrcadNodeRuntime({ conn, host, slotDir: relayDir, target, archivePath }) + expect(execCommand).toHaveBeenCalledTimes(1) + expect(vi.mocked(execCommand).mock.calls[0][2]).toMatchObject({ wrapCommand: false }) + expect(uploadRelayDirectory).not.toHaveBeenCalled() + }) + + it('uploads into the stage the probe created and promotes with a long budget in two execs', async () => { + vi.mocked(execCommand) + .mockResolvedValueOnce(REMOTE_NODE_RUNTIME_MISSING) + .mockResolvedValueOnce(`extracted-by tar\r\n${REMOTE_NODE_RUNTIME_READY}\r\n`) + await ensureRemoteOrcadNodeRuntime({ conn, host, slotDir: relayDir, target, archivePath }) + const calls = vi.mocked(execCommand).mock.calls + // The promote script removes its own stage, so no third powershell.exe runs. + expect(calls).toHaveLength(2) + for (const call of calls) { + expect(call[1]).toMatch(/^powershell\.exe /) + expect(call[2]).toMatchObject({ wrapCommand: false }) + } + const probe = decodeRemotePowerShellScript(calls[0][1]) + const stage = /New-Item -ItemType Directory -Force -Path '([^']+)'/.exec(probe)?.[1] + expect(stage).toMatch( + /^C:\/Users\/u\/\.orca-remote\/runtimes\/\.stage-node-[0-9a-f]{64}-[0-9a-f]{16}$/ + ) + expect(vi.mocked(uploadRelayDirectory).mock.calls[0][2]).toBe(stage) + expect(calls[1][2]).toMatchObject({ timeoutMs: WINDOWS_NODE_RUNTIME_PROMOTE_TIMEOUT_MS }) + }) + + it('still removes the stage when the upload fails before promote runs', async () => { + vi.mocked(execCommand).mockResolvedValueOnce(REMOTE_NODE_RUNTIME_MISSING).mockResolvedValue('') + vi.mocked(uploadRelayDirectory).mockRejectedValueOnce(new Error('sftp write failed')) + await expect( + ensureRemoteOrcadNodeRuntime({ conn, host, slotDir: relayDir, target, archivePath }) + ).rejects.toThrow('sftp write failed') + const calls = vi.mocked(execCommand).mock.calls + expect(calls).toHaveLength(2) + const stage = /New-Item -ItemType Directory -Force -Path '([^']+)'/.exec( + decodeRemotePowerShellScript(calls[0][1]) + )?.[1] + expect(decodeRemotePowerShellScript(calls[1][1])).toBe( + `Remove-Item -LiteralPath '${stage}' -Recurse -Force -ErrorAction SilentlyContinue` + ) + }) + + it('surfaces a post-write change as a security-software verdict', async () => { + vi.mocked(execCommand) + .mockResolvedValueOnce(REMOTE_NODE_RUNTIME_MISSING) + .mockResolvedValueOnce( + 'ORCA_NODE_RUNTIME_SECURITY_MODIFIED node.exe changed after it ran\r\n' + ) + const failure = await ensureRemoteOrcadNodeRuntime({ + conn, + host, + slotDir: relayDir, + target, + archivePath + }).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(RemoteNodeRuntimeSecurityModifiedError) + expect(failure).toMatchObject({ detail: 'node.exe changed after it ran' }) + }) + + it('carries what node.exe said when it would not run', async () => { + vi.mocked(execCommand) + .mockResolvedValueOnce(REMOTE_NODE_RUNTIME_MISSING) + .mockResolvedValueOnce( + "ORCA_NODE_RUNTIME_SELFTEST_FAILED\r\nORCA_RUNTIME_EXIT=-1\r\nProgram 'node.exe' failed to run: This program is blocked by group policy.\r\n" + ) + const failure = await ensureRemoteOrcadNodeRuntime({ + conn, + host, + slotDir: relayDir, + target, + archivePath + }).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(RemoteNodeRuntimeSelfTestError) + expect(failure).toMatchObject({ + exitStatus: -1, + output: expect.stringContaining('blocked by group policy') + }) + }) +}) diff --git a/src/main/ssh/orcad-remote-node-runtime-windows.ts b/src/main/ssh/orcad-remote-node-runtime-windows.ts new file mode 100644 index 000000000000..2fd34c40cbe9 --- /dev/null +++ b/src/main/ssh/orcad-remote-node-runtime-windows.ts @@ -0,0 +1,164 @@ +/** + * The Windows half of the pinned-Node runtime store: `runtimes\node-\node.exe` with a + * `.verified` marker, beside the relay version directories (design D2, D5 "Windows"). + * + * Each phase is ONE PowerShell invocation through `powerShellCommand`, which adds no new + * `-EncodedCommand` site, spells no `-ExecutionPolicy` and compiles nothing (`Add-Type`); see + * docs/reference/windows-edr-posture.md. node.exe keeps upstream's name and layout. + */ +import { + NODE_RUNTIME_ASSETS, + NODE_RUNTIME_PIN, + nodeRuntimeExecutablePath +} from '../../shared/node-runtime-pin' +import type { ServerTarget } from '../../shared/node-runtime-pin' +import { ORCAD_NODE_RUNTIME_WINDOWS_EXECUTABLE } from '../../shared/orcad-artifacts' +import { + REMOTE_NODE_RUNTIME_EXIT_PREFIX, + REMOTE_NODE_RUNTIME_MISSING, + REMOTE_NODE_RUNTIME_READY, + REMOTE_NODE_RUNTIME_SECURITY_MODIFIED, + REMOTE_NODE_RUNTIME_SELFTEST_FAILED, + REMOTE_NODE_RUNTIME_VERIFIED_MARKER +} from './orcad-remote-node-runtime-report' +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' + +/** Promotion may fall back to Expand-Archive, which unpacks the whole ~30 MiB zip. */ +export const WINDOWS_NODE_RUNTIME_PROMOTE_TIMEOUT_MS = 300_000 + +function windowsPath(...segments: string[]): string { + return segments + .map((segment, index) => (index === 0 ? segment.replace(/\/+$/, '') : segment)) + .join('/') +} + +// Why no `$ErrorActionPreference = 'Stop'`: under Windows PowerShell 5.1 it turns any stderr +// line from a native command (tar.exe, node.exe) into a terminating error. +function prelude(): string[] { + return [ + // Why: PS 5.1 progress records slow Expand-Archive badly and leak CLIXML into stdout. + "$ProgressPreference = 'SilentlyContinue'", + 'function Get-OrcaSha256([string]$p) { if (Test-Path -LiteralPath $p -PathType Leaf) { (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash.ToLowerInvariant() } else { "" } }' + ] +} + +function runtimeVariables(runtimeDir: string): string[] { + return [ + `$runtimeDir = ${powerShellLiteral(runtimeDir)}`, + `$exe = ${powerShellLiteral(windowsPath(runtimeDir, ORCAD_NODE_RUNTIME_WINDOWS_EXECUTABLE))}`, + `$verified = ${powerShellLiteral(windowsPath(runtimeDir, REMOTE_NODE_RUNTIME_VERIFIED_MARKER))}` + ] +} + +/** + * Ready only when node.exe hashes to the pin under its marker. When it is not, and a stage is + * named, the same invocation creates the upload stage so the install costs no extra spawn. + */ +export function windowsNodeRuntimeProbeCommand( + runtimeDir: string, + target: ServerTarget, + stageDir?: string +): string { + return powerShellCommand( + [ + ...prelude(), + ...runtimeVariables(runtimeDir), + `if ((Test-Path -LiteralPath $verified -PathType Leaf) -and ((Get-OrcaSha256 $exe) -eq ${powerShellLiteral(NODE_RUNTIME_ASSETS[target].executableSha256)})) { Write-Output ${powerShellLiteral(REMOTE_NODE_RUNTIME_READY)}; exit 0 }`, + ...(stageDir + ? [ + `$null = New-Item -ItemType Directory -Force -Path ${powerShellLiteral(stageDir)} -ErrorAction Stop` + ] + : []), + `Write-Output ${powerShellLiteral(REMOTE_NODE_RUNTIME_MISSING)}` + ].join('\n') + ) +} + +/** Cheap warm-path check: a published runtime has its marker and node.exe; no re-hash. */ +export function windowsNodeRuntimePresentCommand(runtimeDir: string): string { + return powerShellCommand( + [ + ...runtimeVariables(runtimeDir), + `if ((Test-Path -LiteralPath $verified -PathType Leaf) -and (Test-Path -LiteralPath $exe -PathType Leaf)) { Write-Output ${powerShellLiteral(REMOTE_NODE_RUNTIME_READY)} } else { Write-Output ${powerShellLiteral(REMOTE_NODE_RUNTIME_MISSING)} }` + ].join('\n') + ) +} + +/** + * Verify the uploaded zip, extract node.exe (tar.exe, else Expand-Archive), verify it, run it, + * then publish the runtime directory with one directory rename. Bytes that were verified and + * then change or vanish are reported as security software, never retried as a transfer fault. + */ +export function windowsNodeRuntimePromoteCommand(args: { + stageDir: string + archive: string + runtimeDir: string + target: ServerTarget +}): string { + const asset = NODE_RUNTIME_ASSETS[args.target] + const member = nodeRuntimeExecutablePath(args.target, asset.archive) + const modified = (what: string): string => + `Write-Output ${powerShellLiteral(`${REMOTE_NODE_RUNTIME_SECURITY_MODIFIED} ${what}`)}` + return powerShellCommand( + [ + ...prelude(), + ...runtimeVariables(args.runtimeDir), + `$stage = ${powerShellLiteral(args.stageDir)}`, + `$archive = ${powerShellLiteral(windowsPath(args.stageDir, args.archive))}`, + `$extracted = ${powerShellLiteral(windowsPath(args.stageDir, ...member.split('/')))}`, + `$candidate = ${powerShellLiteral(windowsPath(args.stageDir, 'promote'))}`, + `$pin = ${powerShellLiteral(asset.executableSha256)}`, + // Why a function: `return` leaves it and the stage cleanup below still runs, which `exit` would skip. + '$script:code = 0', + 'function Invoke-OrcaPromote {', + `$archiveHash = Get-OrcaSha256 $archive`, + `if ($archiveHash -eq '') { Write-Output 'ORCA_NODE_RUNTIME_ARCHIVE_MISSING'; $script:code = 1; return }`, + `if ($archiveHash -ne ${powerShellLiteral(asset.archiveSha256)}) { ${modified('the uploaded archive changed after it was written')}; return }`, + // Why the System32 path: a Git or MSYS tar.exe earlier on PATH cannot read a zip. + "$tar = Join-Path $env:SystemRoot 'System32\\tar.exe'", + '$extractedBy = $null', + 'if (Test-Path -LiteralPath $tar -PathType Leaf) {', + `$null = & $tar -xf $archive -C $stage ${powerShellLiteral(member)} 2>&1`, + "if ($LASTEXITCODE -eq 0) { $extractedBy = 'tar' }", + '}', + 'if ($null -eq $extractedBy) {', + "try { Expand-Archive -LiteralPath $archive -DestinationPath $stage -Force -ErrorAction Stop; $extractedBy = 'Expand-Archive' } catch { Write-Output ('ORCA_NODE_RUNTIME_EXTRACT_FAILED ' + $_.Exception.Message); $script:code = 1; return }", + '}', + `if (-not (Test-Path -LiteralPath $extracted -PathType Leaf)) { ${modified('node.exe vanished after extraction')}; return }`, + `if ((Get-OrcaSha256 $extracted) -ne $pin) { ${modified('node.exe changed after extraction')}; return }`, + // Why run it: executing is the only reliable check for application control and AV blocks. + "$runOut = ''; $runStatus = $null", + 'try { $runOut = ((& $extracted --version 2>&1) | ForEach-Object { "$_" }) -join "`n"; $runStatus = $LASTEXITCODE } catch { $runStatus = -1; $runOut = $_.Exception.Message }', + `if (($runStatus -ne 0) -or ($runOut.Trim() -ne ${powerShellLiteral(`v${NODE_RUNTIME_PIN.version}`)})) {`, + `Write-Output ${powerShellLiteral(REMOTE_NODE_RUNTIME_SELFTEST_FAILED)}`, + `Write-Output (${powerShellLiteral(REMOTE_NODE_RUNTIME_EXIT_PREFIX)} + $runStatus)`, + 'Write-Output ($runOut.Substring(0, [Math]::Min(4000, $runOut.Length)))', + 'return', + '}', + `if ((Get-OrcaSha256 $extracted) -ne $pin) { ${modified('node.exe changed after it ran')}; return }`, + '$null = New-Item -ItemType Directory -Force -Path $candidate -ErrorAction Stop', + `Move-Item -LiteralPath $extracted -Destination (Join-Path $candidate ${powerShellLiteral(ORCAD_NODE_RUNTIME_WINDOWS_EXECUTABLE)}) -ErrorAction Stop`, + `[IO.File]::WriteAllText((Join-Path $candidate ${powerShellLiteral(REMOTE_NODE_RUNTIME_VERIFIED_MARKER)}), '')`, + '$null = New-Item -ItemType Directory -Force -Path (Split-Path -Parent $runtimeDir) -ErrorAction Stop', + 'if ((Test-Path -LiteralPath $runtimeDir) -and ((Get-OrcaSha256 $exe) -ne $pin)) { Remove-Item -LiteralPath $runtimeDir -Recurse -Force -ErrorAction Stop }', + // Why Directory.Move: it refuses an existing target, where Move-Item would nest inside it. + // Why retry: an on-access scan of the new node.exe can briefly hold the directory. + 'for ($attempt = 1; ($attempt -le 5) -and -not (Test-Path -LiteralPath $runtimeDir); $attempt++) { try { [IO.Directory]::Move($candidate, $runtimeDir) } catch { Start-Sleep -Milliseconds 400 } }', + "if (-not (Test-Path -LiteralPath $exe -PathType Leaf)) { throw 'The pinned Node runtime directory could not be published' }", + "if (-not (Test-Path -LiteralPath $verified -PathType Leaf)) { [IO.File]::WriteAllText($verified, '') }", + `if ((Get-OrcaSha256 $exe) -ne $pin) { Remove-Item -LiteralPath $verified -Force -ErrorAction SilentlyContinue; ${modified('node.exe changed after it was published')}; return }`, + `Write-Output ('extracted-by ' + $extractedBy)`, + `Write-Output ${powerShellLiteral(REMOTE_NODE_RUNTIME_READY)}`, + '}', + 'try { Invoke-OrcaPromote } catch { [Console]::Error.WriteLine($_.Exception.Message); $script:code = 1 }', + 'Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue', + 'exit $script:code' + ].join('\n') + ) +} + +export function windowsNodeRuntimeStageCleanupCommand(stageDir: string): string { + return powerShellCommand( + `Remove-Item -LiteralPath ${powerShellLiteral(stageDir)} -Recurse -Force -ErrorAction SilentlyContinue` + ) +} diff --git a/src/main/ssh/orcad-remote-node-runtime.test.ts b/src/main/ssh/orcad-remote-node-runtime.test.ts new file mode 100644 index 000000000000..9ce30dd4eafc --- /dev/null +++ b/src/main/ssh/orcad-remote-node-runtime.test.ts @@ -0,0 +1,178 @@ +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { runProcessSync } from '../../shared/child-process/run-process' +import { NODE_RUNTIME_ASSETS, type ServerTarget } from '../../shared/node-runtime-pin' +import { ORCAD_NODE_RUNTIME_MARKER_FILENAME } from '../../shared/orcad-artifacts' +import { + installNodeRuntimeFromHostArchiveCommand, + nodeRuntimeStoreDir, + parseRemoteRuntimeExitReport, + probeRemoteNodeRuntimeCommand, + promoteRemoteNodeRuntimeCommand, + remoteNodeRuntimeDir, + REMOTE_NODE_RUNTIME_MISSING, + REMOTE_NODE_RUNTIME_READY +} from './orcad-remote-node-runtime' +import { orcadNodeSlotRuntimeCommand } from './orcad-remote-runtime' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const directories: string[] = [] +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function sh(command: string, ...args: string[]): string { + const result = runProcessSync({ + program: '/bin/sh', + args: ['-c', command, '--', ...args], + timeoutMs: 60_000 + }) + if (result.code !== 0) { + throw new Error(`exit ${result.code}: ${result.stdout}${result.stderr}`) + } + return result.stdout.trim() +} + +function hostTarget(): ServerTarget | null { + if (process.platform === 'darwin') { + return process.arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64' + } + return null +} + +const target = hostTarget() +const archive = target + ? [process.env.ORCA_NODE_RUNTIME_CACHE_DIR, resolve('out/node-runtime-cache')] + .filter((dir): dir is string => !!dir) + .map((dir) => join(dir, NODE_RUNTIME_ASSETS[target].archive)) + .find((path) => existsSync(path)) + : undefined + +describe.skipIf(process.platform === 'win32')('remote pinned-Node runtime store', () => { + it('names the runtime the slot selector resolves, beside the version directories', () => { + const root = mkdtempSync(join(tmpdir(), 'orcad-runtime-store-')) + directories.push(root) + const host = getRemoteHostPlatform('linux-x64') + const slotDir = join(root, 'orcad-0.1.0+aaaaaaaaaaaa') + const target = 'linux-x64-glibc' + const sha = NODE_RUNTIME_ASSETS[target].executableSha256 + mkdirSync(slotDir) + writeFileSync(join(slotDir, ORCAD_NODE_RUNTIME_MARKER_FILENAME), `${sha}\n`) + const runtimeDir = remoteNodeRuntimeDir(host, slotDir, target) + expect(runtimeDir).toBe(join(root, 'runtimes', `node-${sha}`)) + mkdirSync(join(runtimeDir, 'bin'), { recursive: true }) + writeFileSync(join(runtimeDir, 'bin', 'node'), '#!/bin/sh\n', { mode: 0o755 }) + expect(sh(`${orcadNodeSlotRuntimeCommand(host, slotDir)}echo "$orcad_runtime"`)).toBe( + join(runtimeDir, 'bin', 'node') + ) + }) + + it('splits the runtime exit status from the loader output it reported', () => { + expect( + parseRemoteRuntimeExitReport( + 'ORCA_NODE_RUNTIME_SELFTEST_FAILED\nORCA_RUNTIME_EXIT=126\nsh: node: Permission denied\n' + ) + ).toEqual({ exitStatus: 126, output: 'sh: node: Permission denied' }) + expect(parseRemoteRuntimeExitReport('no report')).toEqual({ + exitStatus: null, + output: 'no report' + }) + }) + + it('reports a runtime whose bytes do not hash to the pin as missing', () => { + const root = mkdtempSync(join(tmpdir(), 'orcad-runtime-store-')) + directories.push(root) + const host = getRemoteHostPlatform('linux-x64') + const runtimeDir = join(root, 'runtimes', 'node-x') + mkdirSync(join(runtimeDir, 'bin'), { recursive: true }) + writeFileSync(join(runtimeDir, 'bin', 'node'), 'tampered', { mode: 0o755 }) + writeFileSync(join(runtimeDir, '.verified'), '') + expect(sh(probeRemoteNodeRuntimeCommand(host, runtimeDir, 'linux-x64-glibc'))).toBe( + REMOTE_NODE_RUNTIME_MISSING + ) + }) + + it.skipIf(!archive)( + 'extracts, verifies, self-tests and publishes the official archive on the host', + () => { + const root = mkdtempSync(join(tmpdir(), 'orcad-runtime-store-')) + directories.push(root) + const host = getRemoteHostPlatform(target === 'darwin-arm64' ? 'darwin-arm64' : 'darwin-x64') + const slotDir = join(root, 'orcad-0.1.0+bbbbbbbbbbbb') + const runtimeDir = remoteNodeRuntimeDir(host, slotDir, target!) + const probe = probeRemoteNodeRuntimeCommand(host, runtimeDir, target!) + expect(sh(probe)).toBe(REMOTE_NODE_RUNTIME_MISSING) + + const stageDir = join(root, 'runtimes', '.stage-test') + mkdirSync(stageDir, { recursive: true }) + const archiveName = NODE_RUNTIME_ASSETS[target!].archive + copyFileSync(archive!, join(stageDir, archiveName)) + const promote = promoteRemoteNodeRuntimeCommand(host, { + stageDir, + archive: archiveName, + runtimeDir, + target: target!, + token: 'test' + }) + expect(sh(promote).split('\n').at(-1)).toBe(REMOTE_NODE_RUNTIME_READY) + expect(sh(probe)).toBe(REMOTE_NODE_RUNTIME_READY) + // A second installer of the same pin republishes identical bytes without a torn window. + expect(sh(promote).split('\n').at(-1)).toBe(REMOTE_NODE_RUNTIME_READY) + expect(sh(probe)).toBe(REMOTE_NODE_RUNTIME_READY) + }, + 60_000 + ) + + it('removes its stage and publishes nothing when a host-readable archive fails verification', () => { + const root = mkdtempSync(join(tmpdir(), 'orcad-runtime-store-')) + directories.push(root) + const host = getRemoteHostPlatform('linux-x64') + const runtimeDir = nodeRuntimeStoreDir(host, root, 'linux-x64-glibc') + const source = join(root, 'not-node.tar.gz') + writeFileSync(source, 'not an archive') + const install = installNodeRuntimeFromHostArchiveCommand(host, { + runtimeDir, + archive: 'node.tar.gz', + target: 'linux-x64-glibc', + token: 'test' + }) + expect(() => sh(install, source)).toThrow('ORCA_NODE_RUNTIME_EXTRACT_FAILED') + expect(readdirSync(join(root, 'runtimes'))).toEqual([]) + }) + + it.skipIf(!archive)( + 'installs from a host-readable archive into the store the probe accepts', + () => { + const root = mkdtempSync(join(tmpdir(), 'orcad-runtime-store-')) + directories.push(root) + const host = getRemoteHostPlatform(target === 'darwin-arm64' ? 'darwin-arm64' : 'darwin-x64') + const runtimeDir = nodeRuntimeStoreDir(host, root, target!) + const install = installNodeRuntimeFromHostArchiveCommand(host, { + runtimeDir, + archive: NODE_RUNTIME_ASSETS[target!].archive, + target: target!, + token: 'test' + }) + expect(sh(install, archive!).split('\n').at(-1)).toBe(REMOTE_NODE_RUNTIME_READY) + expect(sh(probeRemoteNodeRuntimeCommand(host, runtimeDir, target!))).toBe( + REMOTE_NODE_RUNTIME_READY + ) + expect(readdirSync(join(root, 'runtimes'))).toEqual([ + `node-${NODE_RUNTIME_ASSETS[target!].executableSha256}` + ]) + }, + 60_000 + ) +}) diff --git a/src/main/ssh/orcad-remote-node-runtime.ts b/src/main/ssh/orcad-remote-node-runtime.ts new file mode 100644 index 000000000000..0303b90fcf46 --- /dev/null +++ b/src/main/ssh/orcad-remote-node-runtime.ts @@ -0,0 +1,325 @@ +/** + * Puts the pinned Node beside the orcad slots, at `runtimes/node-/bin/node` + * (design D2/D5), where `selectOrcadSlotRuntimeCommand` resolves a slot's `.runtime-node`. + * The OpenCode vault reader uses the same store on SSH and WSL hosts (design D4a). + * + * The official archive is uploaded as published and extracted on the host; the executable's + * hash is checked there before it is published. Promotion runs under the store lock that + * store GC also takes (design D5); nothing here deletes a runtime. + */ +import { randomBytes } from 'node:crypto' +import { copyFile, link, mkdtemp, rm } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' +import { + NODE_RUNTIME_ASSETS, + NODE_RUNTIME_PIN, + nodeRuntimeExecutablePath, + type ServerTarget +} from '../../shared/node-runtime-pin' +import { + ORCAD_NODE_RUNTIME_DIR_PREFIX, + ORCAD_NODE_RUNTIME_POSIX_EXECUTABLE, + ORCAD_RUNTIMES_DIRNAME, + orcadNodeRuntimeExecutable +} from '../../shared/orcad-artifacts' +import type { SshConnection } from './ssh-connection' +import { shellEscape } from './ssh-connection-utils' +import { execCommand } from './ssh-relay-deploy-helpers' +import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' +import { uploadRelayDirectory } from './ssh-relay-install-transfers' +import { + isWindowsRemoteHost, + joinRemotePath, + remoteDirname, + type RemoteHostPlatform +} from './ssh-remote-platform' +import { assertPosixOrcadHost } from './orcad-remote-host-support' +import { withRuntimeStoreLock } from './remote-node-runtime-store-lock' +import { + assertRemoteNodeRuntimePromoted, + REMOTE_NODE_RUNTIME_EXIT_PREFIX, + REMOTE_NODE_RUNTIME_MISSING, + REMOTE_NODE_RUNTIME_READY, + REMOTE_NODE_RUNTIME_SELFTEST_FAILED, + REMOTE_NODE_RUNTIME_VERIFIED_MARKER +} from './orcad-remote-node-runtime-report' +import { + windowsNodeRuntimePresentCommand, + windowsNodeRuntimeProbeCommand, + windowsNodeRuntimePromoteCommand, + windowsNodeRuntimeStageCleanupCommand, + WINDOWS_NODE_RUNTIME_PROMOTE_TIMEOUT_MS +} from './orcad-remote-node-runtime-windows' + +export { + parseRemoteRuntimeExitReport, + REMOTE_NODE_RUNTIME_EXIT_PREFIX, + REMOTE_NODE_RUNTIME_MISSING, + REMOTE_NODE_RUNTIME_READY, + REMOTE_NODE_RUNTIME_SECURITY_MODIFIED, + REMOTE_NODE_RUNTIME_SELFTEST_FAILED, + RemoteNodeRuntimeSecurityModifiedError, + RemoteNodeRuntimeSelfTestError +} from './orcad-remote-node-runtime-report' +const VERIFIED_MARKER = REMOTE_NODE_RUNTIME_VERIFIED_MARKER +/** Upload stages sit in the store beside the runtimes they become; store GC sweeps stale ones. */ +export const RUNTIME_STORE_STAGE_PREFIX = '.stage-' + +/** `/runtimes/node-`, the one host layout (design D5). */ +export function nodeRuntimeStoreDir( + host: RemoteHostPlatform, + storeParent: string, + target: ServerTarget +): string { + return joinRemotePath( + host, + storeParent, + ORCAD_RUNTIMES_DIRNAME, + `${ORCAD_NODE_RUNTIME_DIR_PREFIX}${NODE_RUNTIME_ASSETS[target].executableSha256}` + ) +} + +/** + * The runtime directory beside a version dir (an orcad slot or a relay install); the slot + * selector computes the same path, and the vault reader shares the store. + */ +export function remoteNodeRuntimeDir( + host: RemoteHostPlatform, + slotDir: string, + target: ServerTarget +): string { + return nodeRuntimeStoreDir(host, remoteDirname(slotDir.replace(/\/+$/, ''), host), target) +} + +/** The executable inside a POSIX runtime directory. */ +export function posixNodeRuntimeExecutable(host: RemoteHostPlatform, runtimeDir: string): string { + return joinRemotePath(host, runtimeDir, ...ORCAD_NODE_RUNTIME_POSIX_EXECUTABLE.split('/')) +} + +/** A per-installer stage beside the runtime; its dot prefix keeps it out of `node-*` listings. */ +export function nodeRuntimeStageDir( + host: RemoteHostPlatform, + runtimeDir: string, + token: string +): string { + return joinRemotePath( + host, + remoteDirname(runtimeDir, host), + `${RUNTIME_STORE_STAGE_PREFIX}${basename(runtimeDir)}-${token}` + ) +} + +// Why both tools: GNU/busybox ship sha256sum, macOS ships shasum; either prints the digest first. +function sha256Of(path: string): string { + return `{ sha256sum ${path} 2>/dev/null || shasum -a 256 ${path}; } | cut -d' ' -f1` +} + +/** Ready only when the executable hashes to the pin and reports the pinned version. */ +export function probeRemoteNodeRuntimeCommand( + host: RemoteHostPlatform, + runtimeDir: string, + target: ServerTarget +): string { + if (isWindowsRemoteHost(host)) { + return windowsNodeRuntimeProbeCommand(runtimeDir, target) + } + const executable = shellEscape(posixNodeRuntimeExecutable(host, runtimeDir)) + const verified = shellEscape(joinRemotePath(host, runtimeDir, VERIFIED_MARKER)) + return ( + `if [ -f ${verified} ] && [ -x ${executable} ] && ` + + `[ "$(${sha256Of(executable)})" = ${shellEscape(NODE_RUNTIME_ASSETS[target].executableSha256)} ]; ` + + `then echo ${REMOTE_NODE_RUNTIME_READY}; else echo ${REMOTE_NODE_RUNTIME_MISSING}; fi` + ) +} + +/** Cheap warm-path check: a published runtime has its marker and an executable; no re-hash. */ +export function remoteNodeRuntimePresentCommand( + host: RemoteHostPlatform, + runtimeDir: string +): string { + if (isWindowsRemoteHost(host)) { + return windowsNodeRuntimePresentCommand(runtimeDir) + } + const executable = shellEscape( + joinRemotePath(host, runtimeDir, ...ORCAD_NODE_RUNTIME_POSIX_EXECUTABLE.split('/')) + ) + const verified = shellEscape(joinRemotePath(host, runtimeDir, VERIFIED_MARKER)) + return ( + `if [ -f ${verified} ] && [ -x ${executable} ]; ` + + `then echo ${REMOTE_NODE_RUNTIME_READY}; else echo ${REMOTE_NODE_RUNTIME_MISSING}; fi` + ) +} + +/** + * Extract, verify, self-test and publish. The executable is renamed into place file-by-file, + * so a concurrent installer of the same pin only ever replaces identical verified bytes. + */ +export function promoteRemoteNodeRuntimeCommand( + host: RemoteHostPlatform, + args: { + stageDir: string + archive: string + runtimeDir: string + target: ServerTarget + token: string + } +): string { + assertPosixOrcadHost(host) + const asset = NODE_RUNTIME_ASSETS[args.target] + const member = nodeRuntimeExecutablePath(args.target, asset.archive) + const stage = shellEscape(args.stageDir) + const extracted = shellEscape(joinRemotePath(host, args.stageDir, ...member.split('/'))) + const binDir = shellEscape(joinRemotePath(host, args.runtimeDir, 'bin')) + const temporary = shellEscape(joinRemotePath(host, args.runtimeDir, 'bin', `node.${args.token}`)) + const executable = shellEscape(posixNodeRuntimeExecutable(host, args.runtimeDir)) + const verified = shellEscape(joinRemotePath(host, args.runtimeDir, VERIFIED_MARKER)) + return [ + `cd ${stage} || exit 1`, + `tar -xzf ${shellEscape(joinRemotePath(host, args.stageDir, args.archive))} ${shellEscape(member)} || { echo ORCA_NODE_RUNTIME_EXTRACT_FAILED; exit 1; }`, + `[ "$(${sha256Of(extracted)})" = ${shellEscape(asset.executableSha256)} ] || { echo ORCA_NODE_RUNTIME_HASH_MISMATCH; exit 1; }`, + `chmod 755 ${extracted}`, + // Why run it: executing is the only reliable check for noexec mounts and a wrong libc. + // Exit 0 on refusal so the caller receives the loader's words to classify, not a bare exit 1. + `{ orca_rt_out=$(${extracted} --version 2>&1); orca_rt_status=$?; ` + + `[ "$orca_rt_out" = ${shellEscape(`v${NODE_RUNTIME_PIN.version}`)} ] || ` + + `{ echo ${REMOTE_NODE_RUNTIME_SELFTEST_FAILED}; echo "${REMOTE_NODE_RUNTIME_EXIT_PREFIX}$orca_rt_status"; ` + + `printf '%s\\n' "$orca_rt_out" | head -c 4000; exit 0; }; }`, + `mkdir -p ${binDir}`, + `mv -f ${extracted} ${temporary}`, + `mv -f ${temporary} ${executable}`, + `: > ${verified}`, + `echo ${REMOTE_NODE_RUNTIME_READY}` + ].join(' && ') +} + +/** + * Copies the archive at `$1` (a path the host can already read, e.g. a WSL view of the + * client's cache) into a fresh stage, promotes it, and removes the stage whatever happens. + */ +export function installNodeRuntimeFromHostArchiveCommand( + host: RemoteHostPlatform, + args: { runtimeDir: string; archive: string; target: ServerTarget; token: string } +): string { + const stageDir = nodeRuntimeStageDir(host, args.runtimeDir, args.token) + const stage = shellEscape(stageDir) + const promote = promoteRemoteNodeRuntimeCommand(host, { ...args, stageDir }) + const copy = `cp -- "$1" ${shellEscape(joinRemotePath(host, stageDir, args.archive))}` + // Subshell: promote's `exit 1` must still reach the stage cleanup. + return `(umask 077 && mkdir -p ${stage} && ${copy} && ${promote}); status=$?; rm -rf ${stage}; exit $status` +} + +export type RemoteRuntimeStep = (operation: () => Promise) => Promise + +const runDirectly: RemoteRuntimeStep = (operation) => operation() + +export type EnsuredRemoteNodeRuntime = { + executable: string + transfer: 'cached' | 'uploaded' +} + +/** + * Ensures the runtime beside `slotDir` exists on the host, uploading the pinned archive only + * when needed, and returns its executable and whether this call uploaded it. + */ +export async function ensureRemoteOrcadNodeRuntime(options: { + conn: SshConnection + host: RemoteHostPlatform + slotDir: string + target: ServerTarget + /** The locally verified pinned archive (pinned-runtime-materializer). */ + archivePath: () => Promise + signal?: AbortSignal + /** Wraps each host round trip, so a caller can tell an unconfirmed channel from a local failure. */ + remoteStep?: RemoteRuntimeStep +}): Promise { + const { conn, host, target, signal } = options + const remoteStep = options.remoteStep ?? runDirectly + const windows = isWindowsRemoteHost(host) + // Why unwrapped on Windows: these are already self-contained powershell.exe command lines. + const exec = (command: string, execOptions: { signal?: AbortSignal; timeoutMs?: number } = {}) => + remoteStep(() => execCommand(conn, command, { ...execOptions, wrapCommand: !windows })) + const runtimeDir = remoteNodeRuntimeDir(host, options.slotDir, target) + const executable = joinRemotePath( + host, + runtimeDir, + ...orcadNodeRuntimeExecutable(target).split('/') + ) + const token = randomBytes(8).toString('hex') + const stageDir = nodeRuntimeStageDir(host, runtimeDir, token) + // Why the stage rides the probe on Windows: every exec there is a powershell.exe spawn. + const probe = await exec( + windows + ? windowsNodeRuntimeProbeCommand(runtimeDir, target, stageDir) + : probeRemoteNodeRuntimeCommand(host, runtimeDir, target), + { signal } + ) + if (probe.trim() === REMOTE_NODE_RUNTIME_READY) { + return { executable, transfer: 'cached' } + } + const cleanupStage = windows + ? windowsNodeRuntimeStageCleanupCommand(stageDir) + : `rm -rf ${shellEscape(stageDir)}` + let localStage: string | undefined + let stageUnconfirmed = false + let hostRemovedStage = false + try { + const archivePath = await options.archivePath() + const uploadDir = await mkdtemp(join(dirname(archivePath), '.runtime-upload-')) + localStage = uploadDir + const archive = basename(archivePath) + await link(archivePath, join(uploadDir, archive)).catch(() => + copyFile(archivePath, join(uploadDir, archive)) + ) + if (!windows) { + await exec(`mkdir -p ${shellEscape(stageDir)}`, { signal }) + } + await remoteStep(() => uploadRelayDirectory(conn, uploadDir, stageDir, host, { signal })) + const promoted = windows + ? await exec(windowsNodeRuntimePromoteCommand({ stageDir, archive, runtimeDir, target }), { + signal, + timeoutMs: WINDOWS_NODE_RUNTIME_PROMOTE_TIMEOUT_MS + }) + : // Why re-probe under the lock: a sibling installer may have published this pin while we uploaded. + await remoteStep(() => + withRuntimeStoreLock( + conn, + host, + remoteDirname(runtimeDir, host), + async () => + ( + await execCommand(conn, probeRemoteNodeRuntimeCommand(host, runtimeDir, target), { + signal + }) + ).trim() === REMOTE_NODE_RUNTIME_READY + ? REMOTE_NODE_RUNTIME_READY + : execCommand( + conn, + promoteRemoteNodeRuntimeCommand(host, { + stageDir, + archive, + runtimeDir, + target, + token + }), + { signal } + ), + signal + ) + ) + // Why: the Windows promote script removes its stage on every path; skip a second powershell.exe. + hostRemovedStage = windows + assertRemoteNodeRuntimePromoted(promoted) + return { executable, transfer: 'uploaded' } + } catch (error) { + stageUnconfirmed = isUnconfirmedSshCommandTermination(error) + throw error + } finally { + if (localStage) { + await rm(localStage, { recursive: true, force: true }).catch(() => {}) + } + // A transfer that may still be writing keeps its stage; loss of contact is not an exit. + if (!stageUnconfirmed && !hostRemovedStage) { + await exec(cleanupStage).catch(() => {}) + } + } +} diff --git a/src/main/ssh/orcad-remote-preflight.ts b/src/main/ssh/orcad-remote-preflight.ts index 5bba386d4618..40a17bcdde03 100644 --- a/src/main/ssh/orcad-remote-preflight.ts +++ b/src/main/ssh/orcad-remote-preflight.ts @@ -1,12 +1,13 @@ import { randomUUID } from 'node:crypto' -import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' -import { orcadBunRuntimeFilename } from '../../shared/orcad-artifacts' +import { ORCAD_NODE_RUNTIME_IDENTITY } from '../../shared/orcad-node-runtime-identity' +import { ORCAD_NODE_RUNTIME_MARKER_FILENAME } from '../../shared/orcad-artifacts' import { ORCAD_PROFILE_PREFLIGHT_FLAG, ORCAD_PROFILE_PREFLIGHT_TIMEOUT_MS, parseOrcadProfilePreflight } from '../../shared/orcad-profile-preflight' import { assertPosixOrcadHost } from './orcad-remote-host-support' +import { orcadNodeSlotRuntimeCommand } from './orcad-remote-runtime' import { execCommand } from './ssh-relay-deploy-helpers' import { shellEscape } from './ssh-connection-utils' import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' @@ -18,13 +19,16 @@ export function orcadProfilePreflightCommand( nonce: string ): string { assertPosixOrcadHost(host) - return [ + // Why no host-Node fallback: a candidate this client installed is always a Node slot. + const marker = shellEscape(joinRemotePath(host, directory, ORCAD_NODE_RUNTIME_MARKER_FILENAME)) + const launch = [ 'ORCA_BACKGROUND_LAUNCH=1', - shellEscape(joinRemotePath(host, directory, orcadBunRuntimeFilename(host.os))), + '"$orcad_runtime"', shellEscape(joinRemotePath(host, directory, 'orcad.js')), ORCAD_PROFILE_PREFLIGHT_FLAG, shellEscape(nonce) ].join(' ') + return `[ -e ${marker} ] || exit 78; ${orcadNodeSlotRuntimeCommand(host, directory)}${launch}` } /** Failure leaves the incumbent and its data untouched, including an unconfirmed SSH exit. */ @@ -41,5 +45,5 @@ export async function preflightInstalledOrcad(options: { orcadProfilePreflightCommand(options.host, options.remoteInstallDir, nonce), { signal: options.signal, timeoutMs: ORCAD_PROFILE_PREFLIGHT_TIMEOUT_MS } ) - parseOrcadProfilePreflight(output, nonce, ORCAD_BUN_VERSION, options.fullVersion) + parseOrcadProfilePreflight(output, nonce, ORCAD_NODE_RUNTIME_IDENTITY, options.fullVersion) } diff --git a/src/main/ssh/orcad-remote-runtime.test.ts b/src/main/ssh/orcad-remote-runtime.test.ts index 637ab61e670a..2b774d8d2c54 100644 --- a/src/main/ssh/orcad-remote-runtime.test.ts +++ b/src/main/ssh/orcad-remote-runtime.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -6,6 +6,7 @@ import { runProcessSync } from '../../shared/child-process/run-process' import { getRemoteHostPlatform } from './ssh-remote-platform' import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' import { stopOrcadCommand } from './orcad-remote-process-control' +import { shellEscape } from './ssh-connection-utils' const directories: string[] = [] const host = getRemoteHostPlatform('linux-x64') @@ -22,17 +23,44 @@ function fixture(): string { return directory } -function launch(directory: string, nodePath: string) { +const NODE_SHA = 'e4b5a3af0e05c75de2eae013904145f40fe7fc2a6e6f17510128bf45cca4e79b' + +/** A slot beside the shared `runtimes/` dir, as installed under `~/.orca-remote`. */ +function nodeSlot(options: { marker?: string; runtime?: boolean } = {}): string { + const root = fixture() + const slot = join(root, 'orcad-0.1.0-abcdef') + mkdirSync(slot) + writeFileSync(join(slot, '.runtime-node'), options.marker ?? `${NODE_SHA}\n`) + if (options.runtime !== false) { + const binDir = join(root, 'runtimes', `node-${NODE_SHA}`, 'bin') + mkdirSync(binDir, { recursive: true }) + symlinkSync(process.execPath, join(binDir, 'node')) + } + return slot +} + +/** Frozen copy of the Bun-era selector that shipped clients still run (design D7.1 R5). */ +function bunEraSelectorCommand(directory: string, legacyNodePath: string): string { + const runtime = shellEscape(join(directory, 'bun-runtime')) + const target = shellEscape(join(directory, '.build-target')) + return ( + `if [ -e ${target} ] || [ -e ${runtime} ]; then ` + + `[ -x ${runtime} ] || exit 78; orcad_runtime=${runtime}; ` + + `else orcad_runtime=${shellEscape(legacyNodePath)}; fi` + ) +} + +function runSelected(selector: string) { return runProcessSync({ program: '/bin/sh', - args: [ - '-c', - `${selectOrcadSlotRuntimeCommand(host, directory, nodePath)}; ` + - '"$orcad_runtime" -e \'process.stdout.write("selected")\'' - ] + args: ['-c', `${selector}; "$orcad_runtime" -e 'process.stdout.write("selected")'`] }) } +function launch(directory: string, nodePath: string) { + return runSelected(selectOrcadSlotRuntimeCommand(host, directory, nodePath)) +} + describe.skipIf(process.platform === 'win32')('POSIX slot runtime selection', () => { it('returns an unverifiable stop result when a bundled runtime cannot execute', () => { const directory = fixture() @@ -64,4 +92,66 @@ describe.skipIf(process.platform === 'win32')('POSIX slot runtime selection', () it('retains the original runtime for a legacy slot', () => { expect(launch(fixture(), process.execPath)).toMatchObject({ code: 0, stdout: 'selected' }) }) + + it('launches a Node slot from its shared pinned runtime, never host Node', () => { + expect(launch(nodeSlot(), '/missing-host-node')).toMatchObject({ code: 0, stdout: 'selected' }) + }) + + it('accepts a Node slot path with a trailing separator', () => { + expect(launch(`${nodeSlot()}/`, '/missing-host-node')).toMatchObject({ + code: 0, + stdout: 'selected' + }) + }) + + it('refuses a Node slot whose pinned runtime is missing before invoking a working host Node', () => { + expect(launch(nodeSlot({ runtime: false }), process.execPath)).toMatchObject({ + code: 78, + stdout: '' + }) + }) + + it.each([ + ['empty', ''], + ['short', 'abc123'], + ['uppercase', NODE_SHA.toUpperCase()], + ['path traversal', `../${NODE_SHA.slice(3)}`] + ])('refuses a Node slot with a %s marker', (_label, marker) => { + expect(launch(nodeSlot({ marker }), process.execPath)).toMatchObject({ code: 78, stdout: '' }) + }) + + it('prefers the Node marker over Bun files in the same slot', () => { + const slot = nodeSlot() + writeFileSync(join(slot, '.build-target'), 'linux-x64-glibc') + expect(launch(slot, '/missing-host-node')).toMatchObject({ code: 0, stdout: 'selected' }) + }) + + it('shows a Node slot to a Bun-era client as legacy, not as a broken Bun slot', () => { + const slot = nodeSlot() + expect(runSelected(bunEraSelectorCommand(slot, process.execPath))).toMatchObject({ + code: 0, + stdout: 'selected' + }) + }) + + it('keeps the frozen Bun-era selector in step with the current Bun and legacy branches', () => { + const bun = fixture() + writeFileSync(join(bun, '.build-target'), 'linux-x64-glibc') + expect(runSelected(bunEraSelectorCommand(bun, process.execPath)).code).toBe(78) + expect(launch(bun, process.execPath).code).toBe(78) + const legacy = fixture() + expect(runSelected(bunEraSelectorCommand(legacy, process.execPath)).stdout).toBe('selected') + expect(launch(legacy, process.execPath).stdout).toBe('selected') + }) + + it('reads a stop verdict from a Node slot through its pinned runtime', () => { + const slot = nodeSlot() + writeFileSync(join(slot, '.orcad-pid'), String(process.pid)) + const result = runProcessSync({ + program: '/bin/sh', + args: ['-c', stopOrcadCommand(host, slot, { waitSeconds: 1, nodePath: '/missing-host-node' })] + }) + // No readiness file: the pinned runtime ran and reported no PID, so the verdict is unverifiable. + expect(result).toMatchObject({ code: 0, stdout: 'UNKNOWN\n' }) + }) }) diff --git a/src/main/ssh/orcad-remote-runtime.ts b/src/main/ssh/orcad-remote-runtime.ts index 505965620a0e..df63b5a9c185 100644 --- a/src/main/ssh/orcad-remote-runtime.ts +++ b/src/main/ssh/orcad-remote-runtime.ts @@ -1,19 +1,62 @@ -import { ORCAD_BUILD_TARGET_FILENAME, orcadBunRuntimeFilename } from '../../shared/orcad-artifacts' +import { + ORCAD_BUILD_TARGET_FILENAME, + ORCAD_NODE_RUNTIME_DIR_PREFIX, + ORCAD_NODE_RUNTIME_MARKER_FILENAME, + ORCAD_NODE_RUNTIME_POSIX_EXECUTABLE, + ORCAD_RUNTIMES_DIRNAME, + orcadBunRuntimeFilename +} from '../../shared/orcad-artifacts' import { assertPosixOrcadHost } from './orcad-remote-host-support' import { shellEscape } from './ssh-connection-utils' -import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { joinRemotePath, remoteDirname, type RemoteHostPlatform } from './ssh-remote-platform' -/** Only legacy slots may use host Node; an incomplete Bun slot must not change runtimes. */ +/** + * Sets `$orcad_runtime` to the pinned Node a slot's `.runtime-node` names, or exits 78. + * Assumes the marker exists; callers decide what its absence means. + */ +export function orcadNodeSlotRuntimeCommand(host: RemoteHostPlatform, directory: string): string { + assertPosixOrcadHost(host) + const nodeMarker = shellEscape( + joinRemotePath(host, directory, ORCAD_NODE_RUNTIME_MARKER_FILENAME) + ) + const nodeRuntimePrefix = shellEscape( + joinRemotePath( + host, + remoteDirname(directory.replace(/\/+$/, ''), host), + ORCAD_RUNTIMES_DIRNAME, + ORCAD_NODE_RUNTIME_DIR_PREFIX + ) + ) + const nodeExecutable = shellEscape(`/${ORCAD_NODE_RUNTIME_POSIX_EXECUTABLE}`) + return ( + `orcad_node_sha=$(cat ${nodeMarker}) || exit 78; ` + + // Why validate: the digest becomes a path segment, so only a bare sha256 may reach it. + // The leading `(` keeps the pattern parseable when stop embeds this in `$(...)`. + 'case "$orcad_node_sha" in ("" | *[!0-9a-f]*) exit 78;; esac; ' + + '[ "${#orcad_node_sha}" -eq 64 ] || exit 78; ' + + `orcad_runtime=${nodeRuntimePrefix}"$orcad_node_sha"${nodeExecutable}; ` + + '[ -x "$orcad_runtime" ] || exit 78; ' + ) +} + +/** + * The slot's own contents pick its runtime: Node marker, then Bun, then legacy host Node. + * Only legacy slots may use host Node; an incomplete Node or Bun slot must not change runtimes. + */ export function selectOrcadSlotRuntimeCommand( host: RemoteHostPlatform, directory: string, legacyNodePath: string ): string { assertPosixOrcadHost(host) + const nodeMarker = shellEscape( + joinRemotePath(host, directory, ORCAD_NODE_RUNTIME_MARKER_FILENAME) + ) const runtime = shellEscape(joinRemotePath(host, directory, orcadBunRuntimeFilename(host.os))) const target = shellEscape(joinRemotePath(host, directory, ORCAD_BUILD_TARGET_FILENAME)) return ( - `if [ -e ${target} ] || [ -e ${runtime} ]; then ` + + `if [ -e ${nodeMarker} ]; then ${orcadNodeSlotRuntimeCommand(host, directory)}` + + `elif [ -e ${target} ] || [ -e ${runtime} ]; then ` + `[ -x ${runtime} ] || exit 78; orcad_runtime=${runtime}; ` + `else orcad_runtime=${shellEscape(legacyNodePath)}; fi` ) diff --git a/src/main/ssh/pinned-runtime-materializer-node.test.ts b/src/main/ssh/pinned-runtime-materializer-node.test.ts new file mode 100644 index 000000000000..be2c29c7201b --- /dev/null +++ b/src/main/ssh/pinned-runtime-materializer-node.test.ts @@ -0,0 +1,79 @@ +import { createHash } from 'node:crypto' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { + NODE_RUNTIME_ASSETS, + NODE_RUNTIME_PIN, + nodeRuntimeExecutablePath +} from '../../shared/node-runtime-pin' +import { materializeNodeRuntimeArchive } from './pinned-runtime-materializer' + +const TARGET = 'linux-x64-glibc' as const +const originalAsset = { ...NODE_RUNTIME_ASSETS[TARGET] } +let root = '' + +function sha256(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex') +} + +function fetcherFor(body: Uint8Array): typeof fetch { + return vi.fn(async () => new Response(Buffer.from(body), { status: 200 })) +} + +/** A real .tar.gz laid out like the official one, so extraction runs the host's tar. */ +async function nodeDistArchiveFixture(executable: Uint8Array): Promise { + const staging = join(root, 'staging') + const member = nodeRuntimeExecutablePath(TARGET, NODE_RUNTIME_ASSETS[TARGET].archive) + await mkdir(join(staging, member, '..'), { recursive: true }) + await writeFile(join(staging, member), executable) + await writeFile(join(staging, member.split('/')[0]!, 'README.md'), 'not extracted') + const archivePath = join(root, 'archive.tar.gz') + const result = await runProcess({ + program: 'tar', + args: ['-czf', archivePath, '-C', staging, member.split('/')[0]!] + }) + expect(result.code, result.stderr).toBe(0) + return new Uint8Array(await readFile(archivePath)) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-node-runtime-materializer-')) +}) + +afterEach(async () => { + Object.assign(NODE_RUNTIME_ASSETS[TARGET], originalAsset) + await rm(root, { recursive: true, force: true }) +}) + +describe.skipIf(process.platform === 'win32')('pinned Node runtime materializer', () => { + it('keeps the verified archive for upload and refetches only when it is corrupted', async () => { + const archive = await nodeDistArchiveFixture(new TextEncoder().encode('node')) + Object.assign(NODE_RUNTIME_ASSETS[TARGET], { archiveSha256: sha256(archive) }) + const cacheRoot = join(root, 'cache') + const fetcher = fetcherFor(archive) + + const cached = await materializeNodeRuntimeArchive(TARGET, cacheRoot, { fetcher }) + expect(cached.endsWith(originalAsset.archive)).toBe(true) + expect(fetcher).toHaveBeenCalledWith( + expect.stringContaining(`/v${NODE_RUNTIME_PIN.version}/${originalAsset.archive}`), + expect.objectContaining({ redirect: 'follow' }) + ) + expect(await materializeNodeRuntimeArchive(TARGET, cacheRoot, { fetcher })).toBe(cached) + expect(fetcher).toHaveBeenCalledOnce() + + await writeFile(cached, 'torn') + const repaired = await materializeNodeRuntimeArchive(TARGET, cacheRoot, { fetcher }) + expect(repaired).not.toBe(cached) + expect(sha256(new Uint8Array(await readFile(repaired)))).toBe(sha256(archive)) + }) + + it('refuses an archive that does not match the pin before caching it', async () => { + const archive = await nodeDistArchiveFixture(new TextEncoder().encode('node')) + await expect( + materializeNodeRuntimeArchive(TARGET, join(root, 'cache'), { fetcher: fetcherFor(archive) }) + ).rejects.toThrow('Node archive checksum mismatch') + }) +}) diff --git a/src/main/ssh/orcad-bun-runtime-materializer.test.ts b/src/main/ssh/pinned-runtime-materializer.test.ts similarity index 57% rename from src/main/ssh/orcad-bun-runtime-materializer.test.ts rename to src/main/ssh/pinned-runtime-materializer.test.ts index de9ab5d939c4..b5f809c3909d 100644 --- a/src/main/ssh/orcad-bun-runtime-materializer.test.ts +++ b/src/main/ssh/pinned-runtime-materializer.test.ts @@ -3,31 +3,49 @@ import { access, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from ' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { ORCAD_BUN_RUNTIME_FILENAME } from '../../shared/orcad-artifacts' -import { ORCAD_BUN_RELEASE_ASSETS, ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' +import { + NODE_RUNTIME_ASSETS, + NODE_RUNTIME_PIN, + nodeRuntimeExecutablePath, + SERVER_TARGETS, + type ServerTarget +} from '../../shared/node-runtime-pin' import { setMainHttpClient } from '../network/http-client' import { runProcess } from '../../shared/child-process/run-process' -import { materializeCachedOrcadBunRuntime } from './orcad-bun-runtime-materializer' +import { materializeCachedNodeRuntime } from './pinned-runtime-materializer' -const extraction = vi.hoisted(() => ({ executable: new Uint8Array(), executableName: 'bun' })) +const extraction = vi.hoisted(() => ({ executable: new Uint8Array(), member: '' })) vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn(async (spec: { args: string[] }) => { - const extracted = join(spec.args.at(-1)!, 'bun-linux-x64') - await mkdir(extracted, { recursive: true }) - await writeFile(join(extracted, extraction.executableName), extraction.executable) + const flag = spec.args.includes('-C') ? '-C' : '-d' + const extracted = join(spec.args[spec.args.indexOf(flag) + 1]!, ...extraction.member.split('/')) + await mkdir(join(extracted, '..'), { recursive: true }) + await writeFile(extracted, extraction.executable) return { code: 0, stdout: '', stderr: '' } }) })) const TARGET = 'linux-x64-glibc' as const -const originalAsset = { ...ORCAD_BUN_RELEASE_ASSETS[TARGET] } +const originalAssets = structuredClone(NODE_RUNTIME_ASSETS) let cacheRoot = '' function sha256(bytes: Uint8Array): string { return createHash('sha256').update(bytes).digest('hex') } +function runtimeDirFor(target: ServerTarget = TARGET): string { + return join(cacheRoot, 'node', NODE_RUNTIME_ASSETS[target].executableSha256) +} + +function pin(target: ServerTarget, archive: Uint8Array, executable?: Uint8Array): void { + NODE_RUNTIME_ASSETS[target].archiveSha256 = sha256(archive) + if (executable) { + NODE_RUNTIME_ASSETS[target].executableSha256 = sha256(executable) + } + extraction.member = nodeRuntimeExecutablePath(target, NODE_RUNTIME_ASSETS[target].archive) +} + function responseFetcher(body: Uint8Array, declaredLength = body.byteLength): typeof fetch { return vi.fn( async () => @@ -39,56 +57,44 @@ function responseFetcher(body: Uint8Array, declaredLength = body.byteLength): ty } beforeEach(async () => { - extraction.executableName = 'bun' - cacheRoot = await mkdtemp(join(tmpdir(), 'orca-bun-runtime-materializer-')) - Object.assign(ORCAD_BUN_RELEASE_ASSETS[TARGET], originalAsset) + extraction.member = nodeRuntimeExecutablePath(TARGET, NODE_RUNTIME_ASSETS[TARGET].archive) + cacheRoot = await mkdtemp(join(tmpdir(), 'orca-node-runtime-executable-')) }) afterEach(async () => { vi.useRealTimers() vi.unstubAllEnvs() setMainHttpClient(null) - Object.assign(ORCAD_BUN_RELEASE_ASSETS[TARGET], originalAsset) + for (const target of SERVER_TARGETS) { + Object.assign(NODE_RUNTIME_ASSETS[target], originalAssets[target]) + } await rm(cacheRoot, { recursive: true, force: true }) }) -describe('materializeCachedOrcadBunRuntime', () => { - it('caches Windows PE files as .exe without renaming a legacy cache entry', async () => { +describe('materializeCachedNodeRuntime', () => { + it('caches a Windows executable under its upstream name, keyed by its digest', async () => { const target = 'win32-x64' as const - const savedAsset = { ...ORCAD_BUN_RELEASE_ASSETS[target] } const archive = new TextEncoder().encode('windows archive') const executable = new TextEncoder().encode('windows executable') extraction.executable = executable - extraction.executableName = 'bun.exe' - Object.assign(ORCAD_BUN_RELEASE_ASSETS[target], { - sha256: sha256(archive), - executableSha256: sha256(executable) + pin(target, archive, executable) + const runtimePath = await materializeCachedNodeRuntime(target, cacheRoot, { + fetcher: responseFetcher(archive) }) - const runtimeDir = join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, target) - await mkdir(runtimeDir, { recursive: true }) - await writeFile(join(runtimeDir, 'bun-runtime'), 'legacy') - try { - const runtimePath = await materializeCachedOrcadBunRuntime(target, cacheRoot, { - fetcher: responseFetcher(archive) - }) - expect(runtimePath).toBe(join(runtimeDir, 'bun-runtime.exe')) - expect(await readFile(runtimePath)).toEqual(Buffer.from(executable)) - expect(await readFile(join(runtimeDir, 'bun-runtime'), 'utf8')).toBe('legacy') - } finally { - Object.assign(ORCAD_BUN_RELEASE_ASSETS[target], savedAsset) - } + expect(runtimePath).toBe(join(runtimeDirFor(target), 'node.exe')) + expect(await readFile(runtimePath)).toEqual(Buffer.from(executable)) }) it('reuses a checksum-valid cached runtime without fetching', async () => { - const runtime = new TextEncoder().encode('cached bun') - ORCAD_BUN_RELEASE_ASSETS[TARGET].executableSha256 = sha256(runtime) - const runtimeDir = join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET) - const runtimePath = join(runtimeDir, ORCAD_BUN_RUNTIME_FILENAME) + const runtime = new TextEncoder().encode('cached node') + NODE_RUNTIME_ASSETS[TARGET].executableSha256 = sha256(runtime) + const runtimeDir = runtimeDirFor() + const runtimePath = join(runtimeDir, 'node') await mkdir(runtimeDir, { recursive: true }) await writeFile(runtimePath, runtime) const fetcher = vi.fn() - await expect(materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher })).resolves.toBe( + await expect(materializeCachedNodeRuntime(TARGET, cacheRoot, { fetcher })).resolves.toBe( runtimePath ) expect(fetcher).not.toHaveBeenCalled() @@ -97,59 +103,52 @@ describe('materializeCachedOrcadBunRuntime', () => { it('downloads, verifies, extracts, and atomically caches the runtime', async () => { const archive = new TextEncoder().encode('pinned archive') - const executable = new TextEncoder().encode('pinned bun executable') + const executable = new TextEncoder().encode('pinned node executable') extraction.executable = executable - Object.assign(ORCAD_BUN_RELEASE_ASSETS[TARGET], { - sha256: sha256(archive), - executableSha256: sha256(executable) - }) + pin(TARGET, archive, executable) const fetcher = responseFetcher(archive) - const runtimePath = await materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher }) + const runtimePath = await materializeCachedNodeRuntime(TARGET, cacheRoot, { fetcher }) expect(await readFile(runtimePath)).toEqual(Buffer.from(executable)) if (process.platform !== 'win32') { expect((await stat(runtimePath)).mode & 0o111).toBe(0o111) } expect(fetcher).toHaveBeenCalledWith( - expect.stringContaining(`/bun-v${ORCAD_BUN_VERSION}/bun-linux-x64.zip`), + expect.stringContaining(`/v${NODE_RUNTIME_PIN.version}/${originalAssets[TARGET].archive}`), expect.objectContaining({ redirect: 'follow' }) ) - expect((await readdir(join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET))).sort()).toEqual( - [ORCAD_BUN_RUNTIME_FILENAME] - ) + expect(await readdir(runtimeDirFor())).toEqual(['node']) }) it('refuses an oversized declared archive before reading its body', async () => { const fetcher = responseFetcher(new Uint8Array([1]), 200 * 1024 * 1024 + 1) - await expect(materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher })).rejects.toThrow( - 'Bun download exceeded the archive size limit' + await expect(materializeCachedNodeRuntime(TARGET, cacheRoot, { fetcher })).rejects.toThrow( + 'Node download exceeded the archive size limit' ) - await expect( - access(join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET, ORCAD_BUN_RUNTIME_FILENAME)) - ).rejects.toThrow() - expect(await readdir(join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET))).toEqual([]) + await expect(access(join(runtimeDirFor(), 'node'))).rejects.toThrow() + expect(await readdir(runtimeDirFor())).toEqual([]) }) it('removes temporary data after an archive checksum mismatch', async () => { const archive = new TextEncoder().encode('tampered archive') const fetcher = responseFetcher(archive) - await expect(materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher })).rejects.toThrow( - 'Bun archive checksum mismatch' + await expect(materializeCachedNodeRuntime(TARGET, cacheRoot, { fetcher })).rejects.toThrow( + 'Node archive checksum mismatch' ) - expect(await readdir(join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET))).toEqual([]) + expect(await readdir(runtimeDirFor())).toEqual([]) }) it('refuses an executable mismatch even when the archive matches its pin', async () => { const archive = new TextEncoder().encode('pinned archive') extraction.executable = new TextEncoder().encode('incorrect executable') - ORCAD_BUN_RELEASE_ASSETS[TARGET].sha256 = sha256(archive) + pin(TARGET, archive) await expect( - materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher: responseFetcher(archive) }) - ).rejects.toThrow('Bun executable checksum mismatch') - expect(await readdir(join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET))).toEqual([]) + materializeCachedNodeRuntime(TARGET, cacheRoot, { fetcher: responseFetcher(archive) }) + ).rejects.toThrow('Node executable checksum mismatch') + expect(await readdir(runtimeDirFor())).toEqual([]) }) // Both cases spawn for real, so the assertion is against the errno Node actually reports: @@ -159,8 +158,9 @@ describe('materializeCachedOrcadBunRuntime', () => { ['absent', (): string => join(cacheRoot, 'absent-extractor')], ['unreachable through a file', (): string => join(cacheRoot, 'plain-file', 'unzip')] ])('names the %s extractor and the override when it cannot be launched', async (_label, path) => { + const target = 'win32-x64' as const const archive = new TextEncoder().encode('unextractable archive') - ORCAD_BUN_RELEASE_ASSETS[TARGET].sha256 = sha256(archive) + pin(target, archive) await writeFile(join(cacheRoot, 'plain-file'), 'not a directory') const { runProcess: spawnForReal } = await vi.importActual<{ runProcess: typeof runProcess @@ -169,31 +169,28 @@ describe('materializeCachedOrcadBunRuntime', () => { vi.stubEnv('ORCA_UNZIP_BIN', path()) await expect( - materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher: responseFetcher(archive) }) + materializeCachedNodeRuntime(target, cacheRoot, { fetcher: responseFetcher(archive) }) ).rejects.toThrow(/install unzip, or set ORCA_UNZIP_BIN/) - expect(await readdir(join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET))).toEqual([]) + expect(await readdir(runtimeDirFor(target))).toEqual([]) }) it('cleans an aborted download before publishing any executable', async () => { const controller = new AbortController() controller.abort(new Error('deployment cancelled')) await expect( - materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { + materializeCachedNodeRuntime(TARGET, cacheRoot, { fetcher: responseFetcher(new Uint8Array([1])), signal: controller.signal }) ).rejects.toThrow('deployment cancelled') - await expect(access(join(cacheRoot, 'bun'))).rejects.toThrow() + await expect(access(join(cacheRoot, 'node'))).rejects.toThrow() }) }) it('publishes concurrent runtime downloads without removing or replacing the winning executable', async () => { const archive = new TextEncoder().encode('pinned archive') extraction.executable = new TextEncoder().encode('pinned runtime') - Object.assign(ORCAD_BUN_RELEASE_ASSETS[TARGET], { - sha256: sha256(archive), - executableSha256: sha256(extraction.executable) - }) + pin(TARGET, archive, extraction.executable) let finishSecond: (response: Response) => void = () => {} const secondFetcher = vi.fn( () => @@ -201,9 +198,9 @@ it('publishes concurrent runtime downloads without removing or replacing the win finishSecond = resolve }) ) - const second = materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher: secondFetcher }) + const second = materializeCachedNodeRuntime(TARGET, cacheRoot, { fetcher: secondFetcher }) await vi.waitFor(() => expect(secondFetcher).toHaveBeenCalledOnce()) - const firstPath = await materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { + const firstPath = await materializeCachedNodeRuntime(TARGET, cacheRoot, { fetcher: responseFetcher(archive) }) const firstIdentity = await stat(firstPath) @@ -216,19 +213,16 @@ it('publishes concurrent runtime downloads without removing or replacing the win it('repairs a corrupt published runtime beside the old inode and reuses the repair', async () => { const archive = new TextEncoder().encode('pinned archive') extraction.executable = new TextEncoder().encode('pinned runtime') - Object.assign(ORCAD_BUN_RELEASE_ASSETS[TARGET], { - sha256: sha256(archive), - executableSha256: sha256(extraction.executable) - }) - const runtimeDir = join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET) - const runtimePath = join(runtimeDir, ORCAD_BUN_RUNTIME_FILENAME) + pin(TARGET, archive, extraction.executable) + const runtimeDir = runtimeDirFor() + const runtimePath = join(runtimeDir, 'node') await mkdir(runtimeDir, { recursive: true }) await writeFile(runtimePath, 'corrupt') const fetcher = responseFetcher(archive) - const repaired = await materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher }) + const repaired = await materializeCachedNodeRuntime(TARGET, cacheRoot, { fetcher }) expect(repaired).not.toBe(runtimePath) expect(await readFile(repaired)).toEqual(Buffer.from(extraction.executable)) - expect(await materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher })).toBe(repaired) + expect(await materializeCachedNodeRuntime(TARGET, cacheRoot, { fetcher })).toBe(repaired) expect(fetcher).toHaveBeenCalledOnce() expect(await readFile(runtimePath, 'utf8')).toBe('corrupt') }) @@ -236,13 +230,10 @@ it('repairs a corrupt published runtime beside the old inode and reuses the repa it('uses the configured HTTP client for deployment downloads', async () => { const archive = new TextEncoder().encode('proxy archive') extraction.executable = new TextEncoder().encode('proxy runtime') - Object.assign(ORCAD_BUN_RELEASE_ASSETS[TARGET], { - sha256: sha256(archive), - executableSha256: sha256(extraction.executable) - }) + pin(TARGET, archive, extraction.executable) const fetcher = responseFetcher(archive) setMainHttpClient({ fetch: fetcher, proxySession: () => null }) - await materializeCachedOrcadBunRuntime(TARGET, cacheRoot, {}) + await materializeCachedNodeRuntime(TARGET, cacheRoot, {}) expect(fetcher).toHaveBeenCalledOnce() }) @@ -251,10 +242,7 @@ it('allows a progressing download to exceed two minutes', async () => { const first = new TextEncoder().encode('first') const second = new TextEncoder().encode('second') extraction.executable = new TextEncoder().encode('slow runtime') - Object.assign(ORCAD_BUN_RELEASE_ASSETS[TARGET], { - sha256: sha256(Buffer.concat([first, second])), - executableSha256: sha256(extraction.executable) - }) + pin(TARGET, Buffer.concat([first, second]), extraction.executable) let stream: ReadableStreamDefaultController | undefined let signal: AbortSignal | null | undefined const fetcher = vi.fn(async (_url, options) => { @@ -267,15 +255,15 @@ it('allows a progressing download to exceed two minutes', async () => { }) ) }) - const pending = materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher }) + const pending = materializeCachedNodeRuntime(TARGET, cacheRoot, { fetcher }) await vi.waitFor(() => expect(fetcher).toHaveBeenCalledOnce()) await vi.advanceTimersByTimeAsync(90_000) stream!.enqueue(first) await vi.waitFor(async () => { - const runtimeDir = join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET) + const runtimeDir = runtimeDirFor() const temporary = (await readdir(runtimeDir)).find((entry) => entry.startsWith('.download-'))! expect( - (await stat(join(runtimeDir, temporary, ORCAD_BUN_RELEASE_ASSETS[TARGET].filename))).size + (await stat(join(runtimeDir, temporary, NODE_RUNTIME_ASSETS[TARGET].archive))).size ).toBe(first.length) }) await vi.advanceTimersByTimeAsync(90_000) @@ -289,11 +277,11 @@ it('aborts a stalled body and removes the unfinished download', async () => { vi.useFakeTimers() const cancel = vi.fn() const fetcher = vi.fn(async () => new Response(new ReadableStream({ cancel }))) - const pending = materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher }) - const rejected = expect(pending).rejects.toThrow('Bun download stalled') + const pending = materializeCachedNodeRuntime(TARGET, cacheRoot, { fetcher }) + const rejected = expect(pending).rejects.toThrow('Node download stalled') await vi.waitFor(() => expect(fetcher).toHaveBeenCalledOnce()) await vi.advanceTimersByTimeAsync(120_000) await rejected expect(cancel).toHaveBeenCalledOnce() - expect(await readdir(join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET))).toEqual([]) + expect(await readdir(runtimeDirFor())).toEqual([]) }) diff --git a/src/main/ssh/pinned-runtime-materializer.ts b/src/main/ssh/pinned-runtime-materializer.ts new file mode 100644 index 000000000000..7e0fc7cdbea1 --- /dev/null +++ b/src/main/ssh/pinned-runtime-materializer.ts @@ -0,0 +1,228 @@ +/** Download, hash-verify and cache the pinned Node runtime, driven only by its pinned asset. */ +import { createHash, randomUUID } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { chmod, link, mkdir, rm } from 'node:fs/promises' +import { basename, join } from 'node:path' +import { getMainHttpClient, type MainHttpClient } from '../network/http-client' +import { downloadVerifiedArchive, extractRuntimeArchive } from './runtime-archive-download' +import { findOrcadCachePath } from './orcad-cache-path' +import { orcadNodeRuntimeExecutable } from '../../shared/orcad-artifacts' +import { + NODE_RUNTIME_ASSETS, + nodeRuntimeExecutablePath, + nodeRuntimeReleaseUrl, + type ServerTarget +} from '../../shared/node-runtime-pin' + +export type PinnedRuntimeMaterializeOptions = { + fetcher?: MainHttpClient['fetch'] + signal?: AbortSignal +} + +export type PinnedRuntimeArchive = { + /** Shown in errors, e.g. "Node". */ + label: string + url: string + archiveSha256: string +} + +export type PinnedRuntimeExecutable = PinnedRuntimeArchive & { + executableSha256: string + /** Archive-relative path of the executable. */ + member: string + isWindows: boolean + /** Where the verified executable is published; `attempt` > 0 names a repair beside it. */ + cachePath: (attempt: number) => string +} + +function archiveName(archive: PinnedRuntimeArchive): string { + return basename(new URL(archive.url).pathname) +} + +/** The pinned archive, downloaded once and kept by its own name; its hash is checked on every use. */ +export async function materializeVerifiedRuntimeArchive( + archive: PinnedRuntimeArchive, + cacheDir: string, + options: PinnedRuntimeMaterializeOptions +): Promise { + options.signal?.throwIfAborted() + await mkdir(cacheDir, { recursive: true }) + const name = archiveName(archive) + const cached = await findOrcadCachePath( + (attempt) => join(cacheDir, `${attempt ? `repair-${attempt}-` : ''}${name}`), + async (path) => (await fileSha256(path)) === archive.archiveSha256 + ) + if (cached.verified) { + return cached.path + } + const temporaryDir = join(cacheDir, `.download-${process.pid}-${randomUUID()}`) + await mkdir(temporaryDir, { recursive: true }) + try { + const downloaded = join(temporaryDir, name) + await downloadVerifiedArchive( + archive, + downloaded, + options.fetcher ?? getMainHttpClient().fetch, + options.signal + ) + options.signal?.throwIfAborted() + await publishVerified( + downloaded, + cached.path, + archive.archiveSha256, + `${archive.label} archive` + ) + return cached.path + } finally { + await rm(temporaryDir, { recursive: true, force: true }) + } +} + +export async function materializeCachedRuntimeExecutable( + runtime: PinnedRuntimeExecutable, + options: PinnedRuntimeMaterializeOptions +): Promise { + options.signal?.throwIfAborted() + const cached = await findOrcadCachePath( + runtime.cachePath, + async (path) => (await fileSha256(path)) === runtime.executableSha256 + ) + const runtimePath = cached.path + if (cached.verified) { + if (!runtime.isWindows) { + await chmod(runtimePath, 0o755) + } + return runtimePath + } + const runtimeDir = join(runtimePath, '..') + await mkdir(runtimeDir, { recursive: true }) + const temporaryDir = join(runtimeDir, `.download-${process.pid}-${randomUUID()}`) + await mkdir(temporaryDir, { recursive: true }) + try { + const archivePath = join(temporaryDir, archiveName(runtime)) + await downloadVerifiedArchive( + runtime, + archivePath, + options.fetcher ?? getMainHttpClient().fetch, + options.signal + ) + options.signal?.throwIfAborted() + const extractedDir = join(temporaryDir, 'extracted') + await mkdir(extractedDir) + const result = await extractRuntimeArchive(runtime, archivePath, extractedDir, options.signal) + options.signal?.throwIfAborted() + if (result.code !== 0) { + throw new Error( + `${runtime.label} archive extraction failed: ${result.stderr || result.stdout}` + ) + } + const executable = join(extractedDir, ...runtime.member.split('/')) + await verifyFileSha256(executable, runtime.executableSha256, `${runtime.label} executable`) + if (!runtime.isWindows) { + await chmod(executable, 0o755) + } + options.signal?.throwIfAborted() + await publishVerified( + executable, + runtimePath, + runtime.executableSha256, + `cached ${runtime.label} executable` + ) + return runtimePath + } finally { + await rm(temporaryDir, { recursive: true, force: true }) + } +} + +/** Hard-link publication: a concurrent winner's identical bytes are kept, never replaced. */ +async function publishVerified( + source: string, + destination: string, + sha256: string, + label: string +): Promise { + try { + await link(source, destination) + } catch (error) { + if ((await fileSha256(destination)) !== sha256) { + throw new Error(`${label} cache entry is unavailable or corrupted: ${destination}`, { + cause: error + }) + } + } + await verifyFileSha256(destination, sha256, label) +} + +function nodeRuntimeArchive(target: ServerTarget): PinnedRuntimeArchive { + const asset = NODE_RUNTIME_ASSETS[target] + return { + label: 'Node', + url: nodeRuntimeReleaseUrl(asset.source, asset.archive), + archiveSha256: asset.archiveSha256 + } +} + +/** The pinned official archive, uploaded to hosts as published (design D5). */ +export function materializeNodeRuntimeArchive( + target: ServerTarget, + cacheRoot: string, + options: PinnedRuntimeMaterializeOptions +): Promise { + return materializeVerifiedRuntimeArchive( + nodeRuntimeArchive(target), + join(cacheRoot, 'node', 'archives'), + options + ) +} + +/** + * Client cache keyed by content (design D2): `node//node[.exe]`. Only hosts + * that cannot extract the archive themselves get the bare executable (Windows SSH vault reads). + */ +export function materializeCachedNodeRuntime( + target: ServerTarget, + cacheRoot: string, + options: PinnedRuntimeMaterializeOptions +): Promise { + const asset = NODE_RUNTIME_ASSETS[target] + const executableName = basename(orcadNodeRuntimeExecutable(target)) + return materializeCachedRuntimeExecutable( + { + ...nodeRuntimeArchive(target), + executableSha256: asset.executableSha256, + member: nodeRuntimeExecutablePath(target, asset.archive), + isWindows: target.startsWith('win32-'), + cachePath: (attempt) => + join( + cacheRoot, + 'node', + asset.executableSha256, + `${attempt ? `repair-${attempt}-` : ''}${executableName}` + ) + }, + options + ) +} + +export async function verifyFileSha256( + path: string, + expected: string, + label: string +): Promise { + const actual = await fileSha256(path) + if (actual !== expected) { + throw new Error(`${label} checksum mismatch: expected ${expected}, got ${actual ?? 'missing'}`) + } +} + +export async function fileSha256(path: string): Promise { + try { + const hash = createHash('sha256') + for await (const chunk of createReadStream(path)) { + hash.update(chunk) + } + return hash.digest('hex') + } catch { + return null + } +} diff --git a/src/main/ssh/relay-version-dir-liveness.test.ts b/src/main/ssh/relay-version-dir-liveness.test.ts new file mode 100644 index 000000000000..0d05ae6bc957 --- /dev/null +++ b/src/main/ssh/relay-version-dir-liveness.test.ts @@ -0,0 +1,131 @@ +/** Runs the generated POSIX liveness probe through a real `/bin/sh` against real sockets. */ +import { execFileSync, spawnSync } from 'node:child_process' +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createServer, type Server } from 'node:net' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { RELAY_PID_FILENAME } from '../../shared/relay-artifacts' +import { + parseRelayVersionDirLiveness, + relayVersionDirLivenessCommand +} from './relay-version-dir-liveness' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const host = getRemoteHostPlatform('linux-x64') +const posixOnly = process.platform === 'win32' ? describe.skip : describe + +function probe(dir: string, nodePath: string | undefined = process.execPath): string { + return execFileSync('/bin/sh', ['-c', relayVersionDirLivenessCommand(host, dir, { nodePath })], { + encoding: 'utf8' + }) +} + +/** A socket inode left by a SIGKILLed listener: connect is refused. Returns the dead PID. */ +function leaveStaleSocket(sockPath: string): number { + const child = spawnSync( + process.execPath, + [ + '-e', + 'require("net").createServer().listen(process.argv[1],()=>{' + + 'process.stdout.write(String(process.pid));process.kill(process.pid,"SIGKILL")})', + sockPath + ], + { encoding: 'utf8' } + ) + return Number.parseInt(child.stdout, 10) +} + +posixOnly('relayVersionDirLivenessCommand (real shell)', () => { + const dirs: string[] = [] + const servers: Server[] = [] + afterEach(async () => { + await Promise.all( + servers.splice(0).map((server) => new Promise((done) => server.close(() => done()))) + ) + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + function versionDir(): string { + // Short base: sun_path caps socket paths near 104 bytes on macOS. + const dir = mkdtempSync(join('/tmp', 'rvl-')) + dirs.push(dir) + return dir + } + + it('is exited for a stale socket whose recorded PID is dead', () => { + const dir = versionDir() + const deadPid = leaveStaleSocket(join(dir, 'relay-a.sock')) + writeFileSync(join(dir, RELAY_PID_FILENAME), `${deadPid}\n`) + + expect(parseRelayVersionDirLiveness(probe(dir))).toBe('exited') + }) + + it('is live for a stale socket whose recorded PID is still running', () => { + const dir = versionDir() + leaveStaleSocket(join(dir, 'relay-a.sock')) + writeFileSync(join(dir, RELAY_PID_FILENAME), `${process.pid}\n`) + + expect(parseRelayVersionDirLiveness(probe(dir))).toBe('live') + }) + + it('keeps the test -S rule for a refused socket without a PID file', () => { + const dir = versionDir() + leaveStaleSocket(join(dir, 'relay-a.sock')) + + expect(parseRelayVersionDirLiveness(probe(dir))).toBe('live') + }) + + it('is exited without a PID file only when no socket is left', () => { + expect(parseRelayVersionDirLiveness(probe(versionDir()))).toBe('exited') + }) + + it('is live when another relay of this build still accepts on its socket', async () => { + const dir = versionDir() + const deadPid = leaveStaleSocket(join(dir, 'relay-a.sock')) + writeFileSync(join(dir, RELAY_PID_FILENAME), `${deadPid}\n`) + const server = createServer() + servers.push(server) + await new Promise((done) => server.listen(join(dir, 'relay-b.sock'), done)) + + expect(parseRelayVersionDirLiveness(probe(dir))).toBe('live') + }) + + it('is unverifiable when the connect probe times out', () => { + const dir = versionDir() + const deadPid = leaveStaleSocket(join(dir, 'relay-a.sock')) + writeFileSync(join(dir, RELAY_PID_FILENAME), `${deadPid}\n`) + // Stands in for node: the connect probe prints `unknown` when its timer fires first. + const timedOutNode = join(dir, 'node') + writeFileSync(timedOutNode, '#!/bin/sh\nprintf unknown\n') + chmodSync(timedOutNode, 0o755) + + expect(parseRelayVersionDirLiveness(probe(dir, timedOutNode))).toBe('unverifiable') + }) + + it('is unverifiable when no Node is available to test a leftover socket', () => { + const dir = versionDir() + const deadPid = leaveStaleSocket(join(dir, 'relay-a.sock')) + writeFileSync(join(dir, RELAY_PID_FILENAME), `${deadPid}\n`) + + expect(parseRelayVersionDirLiveness(probe(dir, ''))).toBe('unverifiable') + }) + + it('is unverifiable for an unreadable PID record', () => { + const dir = versionDir() + writeFileSync(join(dir, RELAY_PID_FILENAME), 'not-a-pid\n') + + expect(parseRelayVersionDirLiveness(probe(dir))).toBe('unverifiable') + }) +}) + +describe('parseRelayVersionDirLiveness', () => { + it('maps the Windows pipe vocabulary and treats anything else as unverifiable', () => { + expect(parseRelayVersionDirLiveness('ALIVE')).toBe('live') + expect(parseRelayVersionDirLiveness('WAITING')).toBe('exited') + expect(parseRelayVersionDirLiveness('DEAD\n')).toBe('exited') + expect(parseRelayVersionDirLiveness('')).toBe('unverifiable') + expect(parseRelayVersionDirLiveness('UNKNOWN')).toBe('unverifiable') + }) +}) diff --git a/src/main/ssh/relay-version-dir-liveness.ts b/src/main/ssh/relay-version-dir-liveness.ts new file mode 100644 index 000000000000..1a41d16f4f6f --- /dev/null +++ b/src/main/ssh/relay-version-dir-liveness.ts @@ -0,0 +1,65 @@ +/** + * Whether a relay version directory is still in use, answered with the execution-boundary + * vocabulary (docs/reference/ssh-execution-boundary.md): `live` / `unverifiable` / `exited`. + * + * Design D5: a directory is `exited` only when its recorded `.relay-pid` is provably dead AND + * every `relay-*.sock` in it refuses a connection. The PID is checked first so a live daemon + * about to idle is never connected to (a connection would cancel its grace timer). A directory + * with no PID file was last used by a relay that predates it and keeps the `test -S` rule. + */ +import { RELAY_PID_FILENAME } from '../../shared/relay-artifacts' +import { shellEscape } from './ssh-connection-utils' +import { posixProcessAliveShellFunction } from './orcad-remote-host-support' +import { RELAY_CONNECT_PROBE_JS, type RelayEndpointVerdict } from './ssh-relay-endpoint-incumbent' +import { relayLivenessProbeCommand, type WindowsRelayLivenessOptions } from './ssh-remote-commands' +import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' + +export function relayVersionDirLivenessCommand( + host: RemoteHostPlatform, + dir: string, + options: { nodePath?: string; windows?: WindowsRelayLivenessOptions } = {} +): string { + if (isWindowsRemoteHost(host)) { + return relayLivenessProbeCommand(host, dir, options.windows) + } + return [ + `dir=${shellEscape(dir)}`, + `node=${shellEscape(options.nodePath ?? '')}`, + `pid_file="$dir"/${RELAY_PID_FILENAME}`, + 'socks=', + 'for f in "$dir"/relay-*.sock "$dir"/relay.sock; do [ -S "$f" ] && socks=yes; done', + 'if [ ! -e "$pid_file" ]; then', + ' if [ -n "$socks" ]; then echo LIVE; else echo EXITED; fi', + ' exit 0', + 'fi', + // Prints UNKNOWN and exits when kill -0 fails for any reason but "No such process". + posixProcessAliveShellFunction({ refuseUnverifiable: true }), + 'pid=$(cat "$pid_file" 2>/dev/null) || { echo UNVERIFIABLE; exit 0; }', + 'case "$pid" in "" | *[!0-9]*) echo UNVERIFIABLE; exit 0;; esac', + 'if orcad_alive "$pid"; then echo LIVE; exit 0; fi', + 'for f in "$dir"/relay-*.sock "$dir"/relay.sock; do', + ' [ -S "$f" ] || continue', + ' [ -n "$node" ] || { echo UNVERIFIABLE; exit 0; }', + // Another relay of this build may share the dir under its own socket; only a refusal clears it. + ` r=$("$node" -e ${shellEscape(RELAY_CONNECT_PROBE_JS)} "$f" 2>/dev/null) || r=unknown`, + ' case "$r" in', + ' refused | absent) ;;', + ' accepted) echo LIVE; exit 0;;', + ' *) echo UNVERIFIABLE; exit 0;;', + ' esac', + 'done', + 'echo EXITED' + ].join('\n') +} + +export function parseRelayVersionDirLiveness(output: string): RelayEndpointVerdict { + const token = output.trim().split('\n').pop()?.trim() ?? '' + // ALIVE / DEAD / WAITING are the Windows pipe probe's vocabulary. + if (token === 'LIVE' || token === 'ALIVE') { + return 'live' + } + if (token === 'EXITED' || token === 'DEAD' || token === 'WAITING') { + return 'exited' + } + return 'unverifiable' +} diff --git a/src/main/ssh/remote-install-gc-termination.test.ts b/src/main/ssh/remote-install-gc-termination.test.ts index 73b50e7b6f1f..7cf6ff3f2082 100644 --- a/src/main/ssh/remote-install-gc-termination.test.ts +++ b/src/main/ssh/remote-install-gc-termination.test.ts @@ -11,6 +11,7 @@ import { gcOldRelayVersions } from './remote-install-gc' import { execCommand } from './ssh-relay-deploy-helpers' import { gcRelayNativeDepsCache } from './ssh-relay-native-deps-cache-gc' import { gcRemoteRipgrepCache } from './ssh-relay-ripgrep-cache-gc' +import { REMOTE_INSTALL_ORDER_OK } from './remote-install-previous-version' import { getRemoteHostPlatform } from './ssh-remote-platform' // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: all connection access is replaced by execCommand's mock. @@ -41,8 +42,17 @@ function collectRelayVersions(): Promise { return gcOldRelayVersions(conn, home, currentDir, host, { nativeDepsCacheKeys: [nativeKey] }) } +// ddd is the previous build, so it is pinned and never probed. +const installOrder = [ + `${home}/.orca-remote/relay-0.1.0+bbb/.install-complete`, + `${home}/.orca-remote/relay-0.1.0+ddd/.install-complete`, + `${home}/.orca-remote/relay-0.1.0+aaa/.install-complete`, + REMOTE_INSTALL_ORDER_OK +].join('\n') + const versionSteps = [ - ['listing', 'relay-0.1.0+aaa\nrelay-0.1.0+ccc'], + ['listing', 'relay-0.1.0+aaa\nrelay-0.1.0+ddd'], + ['previous install order', installOrder], ['install lock probe', 'OPEN'], ['completion probe', 'COMPLETE'], ['liveness probe', 'DEAD'], @@ -73,8 +83,8 @@ describe('version GC termination', () => { 'stops after an unconfirmed stale lock probe with claim held: %s', async (claimed) => { const replies = claimed - ? versionSteps.slice(0, 6).map(([, reply]) => String(reply)) - : [versionSteps[0][1]] + ? versionSteps.slice(0, 7).map(([, reply]) => String(reply)) + : versionSteps.slice(0, 2).map(([, reply]) => String(reply)) const error = failAfter([...replies, 'LOCKED']) await expect(collectRelayVersions()).rejects.toBe(error) diff --git a/src/main/ssh/remote-install-gc.ts b/src/main/ssh/remote-install-gc.ts index 986154e356e6..532a350681aa 100644 --- a/src/main/ssh/remote-install-gc.ts +++ b/src/main/ssh/remote-install-gc.ts @@ -28,7 +28,6 @@ import { MAX_RELAY_GC_LISTING_ENTRIES, moveRemoteTreeCommand, probeFileExistsCommand, - relayLivenessProbeCommand, removeRemoteTreeCommand } from './ssh-remote-commands' import { @@ -39,7 +38,13 @@ import { type RemoteHostPlatform } from './ssh-remote-platform' import { windowsRelayPipePathsForSocketName } from './ssh-relay-endpoints' +import type { RelayEndpointVerdict } from './ssh-relay-endpoint-incumbent' import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' +import { findPreviousRemoteInstall } from './remote-install-previous-version' +import { + parseRelayVersionDirLiveness, + relayVersionDirLivenessCommand +} from './relay-version-dir-liveness' // Legacy relay dirs predate `.install-complete`; they need a liveness-only GC check so they // eventually drain. There is no orcad equivalent — orcad has never shipped without one. @@ -68,13 +73,19 @@ export type RemoteInstallGcOptions = { * the rollback target, and GC'ing it turns a recoverable bad update into a re-deploy. */ pinnedDirNames?: readonly string[] + /** + * More pins, resolved only once a candidate exists. Null means the host could not say which + * directories to keep, so this pass deletes nothing. + */ + resolveExtraPinnedDirNames?: () => Promise } /** * Garbage-collect one model's old version directories. * - * **GC ownership (design §06 falsifier 1):** a pass only ever sees, and only ever deletes, - * directories belonging to `model`. The remote listing is scoped by prefix, and + * **GC ownership (design D10; to be tracked in docs/reference/remote-server-install-model.md):** + * a pass only ever sees, and only ever deletes, directories belonging to `model`. The remote + * listing is scoped by prefix, and * `remoteInstallGcPermits` re-checks every candidate locally, so neither a widened glob nor * a hand-rolled listing can make one model delete the other's live install. */ @@ -121,10 +132,17 @@ export async function gcOldRemoteInstallVersions( if (candidates.length === 0) { return } + const extraPins = options.resolveExtraPinnedDirNames + ? await options.resolveExtraPinnedDirNames() + : [] + if (!extraPins) { + return + } + const survivors = candidates.filter((name) => !extraPins.includes(name)) const removed: string[] = [] const kept: string[] = [] - for (const name of candidates) { + for (const name of survivors) { const dir = joinRemotePath(host, baseDir, name) try { const safe = await isCandidateSafeToRemove(conn, model, dir, name, host, options) @@ -249,8 +267,8 @@ async function isCandidateSafeToRemove( } /** - * The relay's GC, bound to its own namespace and its own liveness probe (a live unix socket - * or Windows pipe inside the version dir). + * The relay's GC, bound to its own namespace. A version dir goes only on an `exited` verdict + * (relay-version-dir-liveness.ts), and the previous completed build is pinned (design D5). */ export async function gcOldRelayVersions( conn: SshConnection, @@ -260,6 +278,8 @@ export async function gcOldRelayVersions( options?: { windowsNodePath?: string windowsSockNames?: string[] + /** Host Node that runs the connect probe once a recorded relay PID is dead. */ + nodePath?: string /** * Cache entries this connection depends on, whether or not it links to them. Also the gate: * a caller that could not compute a key is not using the shared-cache model on this host, and @@ -270,7 +290,22 @@ export async function gcOldRelayVersions( ): Promise { await gcOldRemoteInstallVersions(conn, RELAY_INSTALL_MODEL, remoteHome, currentDirAbsPath, host, { ...options, - isDirLive: (dir) => hasLiveRelaySocket(conn, dir, host, options) + resolveExtraPinnedDirNames: async () => { + const previous = await findPreviousRemoteInstall( + conn, + host, + joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR), + RELAY_INSTALL_MODEL, + remoteBasename(currentDirAbsPath, host) + ) + // Why null rather than a guess: without the order, any candidate could be the previous build. + if (previous.state !== 'ok') { + return null + } + return previous.dirName ? [previous.dirName] : [] + }, + isDirLive: async (dir) => + (await probeRelayVersionDirLiveness(conn, dir, host, options)) !== 'exited' }) // Why after and not before: version-dir removal is what turns a cache entry unreferenced, so // running it second lets one pass reclaim both instead of leaving the tree for the next connect. @@ -285,18 +320,19 @@ export async function gcOldRelayVersions( } } -async function hasLiveRelaySocket( +/** GC deletes a relay version dir only on `exited`; see relay-version-dir-liveness.ts. */ +export async function probeRelayVersionDirLiveness( conn: SshConnection, dir: string, host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, options?: { windowsNodePath?: string windowsSockNames?: string[] + nodePath?: string } -): Promise { +): Promise { try { - // Why: `test -S` only — a connect-and-close probe would race with a daemon about to idle. - const windowsOptions = + const windows = isWindowsRemoteHost(host) && options?.windowsNodePath ? { nodePath: options.windowsNodePath, @@ -308,15 +344,14 @@ async function hasLiveRelaySocket( const out = await execHostCommand( conn, host, - relayLivenessProbeCommand(host, dir, windowsOptions) + relayVersionDirLivenessCommand(host, dir, { nodePath: options?.nodePath, windows }) ) - const state = out.trim() - return state !== 'DEAD' && state !== 'WAITING' + return parseRelayVersionDirLiveness(out) } catch (err) { if (isUnconfirmedSshCommandTermination(err)) { throw err } - // Why: an inconclusive liveness probe must never authorize deletion. - return true + // Why: an unanswered probe observes nothing; it never authorizes deletion. + return 'unverifiable' } } diff --git a/src/main/ssh/remote-install-model.test.ts b/src/main/ssh/remote-install-model.test.ts index fa32ea81940d..20d2303aeb94 100644 --- a/src/main/ssh/remote-install-model.test.ts +++ b/src/main/ssh/remote-install-model.test.ts @@ -3,8 +3,11 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it } from 'vitest' -import { ORCAD_RIPGREP_ARTIFACTS } from '../../shared/orcad-artifacts' -import { probeRemoteInstallCompleteCommand } from './ssh-remote-commands' +import { orcadRipgrepArtifact } from '../../shared/orcad-artifacts' +import { + listRemoteInstallBaseDirsCommand, + probeRemoteInstallCompleteCommand +} from './ssh-remote-commands' import { getRemoteHostPlatform } from './ssh-remote-platform' import { @@ -21,12 +24,17 @@ import { const RELAY_DIRS = ['relay-0.1.0+abcdef123456', 'relay-v0.1.0', 'relay-1.2.3'] const ORCAD_DIRS = ['orcad-0.1.0+abcdef123456', 'orcad-v0.1.0', 'orcad-1.2.3'] -it.each([false, true])( - 'requires the executable and both profile workers on Windows=%s', - (isWindows) => { - const artifacts = ORCAD_INSTALL_MODEL.requiredArtifacts(isWindows) - expect(artifacts).toContain(isWindows ? 'bun-runtime.exe' : 'bun-runtime') - expect(artifacts).not.toContain(isWindows ? 'bun-runtime' : 'bun-runtime.exe') +it.each(['linux-x64', 'darwin-arm64', 'win32-x64'] as const)( + 'requires the runtime reference, node-pty and both profile workers on %s', + (platform) => { + const isWindows = platform.startsWith('win32') + const artifacts = ORCAD_INSTALL_MODEL.requiredArtifacts(getRemoteHostPlatform(platform)) + expect(artifacts).toContain('.runtime-node') + expect(artifacts).not.toContain('bun-runtime') + expect(artifacts).not.toContain('bun-runtime.exe') + expect(artifacts).toContain( + `node_modules/node-pty/build/Release/${isWindows ? 'conpty.node' : 'pty.node'}` + ) expect(artifacts).toContain('profile-state-writer-worker-entry.js') expect(artifacts).toContain('profile-state-backup-worker-entry.js') expect(artifacts.includes('windows-process-tree.node')).toBe(isWindows) @@ -39,30 +47,29 @@ describe('remote install namespace', () => { expect(remoteInstallDirName(ORCAD_INSTALL_MODEL, '0.1.0+aa')).toBe('orcad-0.1.0+aa') }) - it('requires every shipped search binary in a completed standalone runtime install', () => { - const required = ORCAD_INSTALL_MODEL.requiredArtifacts(false) - expect(required).toEqual(expect.arrayContaining([...ORCAD_RIPGREP_ARTIFACTS])) - expect(ORCAD_INSTALL_MODEL.requiredArtifacts(true)).toEqual( - expect.arrayContaining([...ORCAD_RIPGREP_ARTIFACTS]) - ) + it("requires the host's own search binary in a completed standalone runtime install", () => { + for (const platform of ['linux-arm64', 'darwin-x64', 'win32-arm64'] as const) { + expect( + ORCAD_INSTALL_MODEL.requiredArtifacts(getRemoteHostPlatform(platform)).filter( + (file) => file.startsWith('ripgrep/') && !file.startsWith('ripgrep/licenses/') + ) + ).toEqual([orcadRipgrepArtifact(platform)]) + } }) it.skipIf(process.platform === 'win32')('rejects an install missing its search binary', () => { const dir = mkdtempSync(join(tmpdir(), 'orcad-remote-probe-')) try { - const required = [...ORCAD_INSTALL_MODEL.requiredArtifacts(false), '.install-complete'] + const host = getRemoteHostPlatform('linux-x64') + const required = [...ORCAD_INSTALL_MODEL.requiredArtifacts(host), '.install-complete'] for (const filename of required) { const path = join(dir, filename) mkdirSync(dirname(path), { recursive: true }) writeFileSync(path, '') } - const command = probeRemoteInstallCompleteCommand( - getRemoteHostPlatform('linux-x64'), - dir, - required - ) + const command = probeRemoteInstallCompleteCommand(host, dir, required) expect(execFileSync('sh', ['-c', command], { encoding: 'utf8' }).trim()).toBe('OK') - rmSync(join(dir, ORCAD_RIPGREP_ARTIFACTS[0])) + rmSync(join(dir, orcadRipgrepArtifact('linux-x64'))) expect(execFileSync('sh', ['-c', command], { encoding: 'utf8' }).trim()).toBe('MISSING') } finally { rmSync(dir, { recursive: true, force: true }) @@ -116,4 +123,35 @@ describe('GC ownership — each model collects only its own namespace', () => { expect(inventory.orcad).toEqual(ORCAD_DIRS) expect(inventory.unknown).toEqual(['something-else']) }) + + it('gives the shared runtime store its own owner that no version-dir GC may take', () => { + expect(remoteInstallDirOwner('runtimes')).toBe('runtimes') + expect(remoteInstallGcPermits(RELAY_INSTALL_MODEL, 'runtimes')).toBe(false) + expect(remoteInstallGcPermits(ORCAD_INSTALL_MODEL, 'runtimes')).toBe(false) + expect(inventoryRemoteInstallDirs(['runtimes', ...RELAY_DIRS]).runtimes).toEqual(['runtimes']) + }) + + it.skipIf(process.platform === 'win32')( + 'keeps runtimes/ out of every model listing, including older clients’ prefix scans', + () => { + const base = mkdtempSync(join(tmpdir(), 'install-listing-')) + try { + for (const name of ['runtimes', 'relay-0.1.0+aa', 'orcad-0.1.0+aa']) { + mkdirSync(join(base, name)) + } + mkdirSync(join(base, 'runtimes', `node-${'a'.repeat(64)}`)) + const host = getRemoteHostPlatform('linux-x64') + for (const model of [RELAY_INSTALL_MODEL, ORCAD_INSTALL_MODEL]) { + const listed = execFileSync( + '/bin/sh', + ['-c', listRemoteInstallBaseDirsCommand(host, base, model)], + { encoding: 'utf8' } + ) + expect(listed.trim().split('\n')).toEqual([`${model.dirPrefix}-0.1.0+aa`]) + } + } finally { + rmSync(base, { recursive: true, force: true }) + } + } + ) }) diff --git a/src/main/ssh/remote-install-model.ts b/src/main/ssh/remote-install-model.ts index e2a46fda5475..f547c02dd104 100644 --- a/src/main/ssh/remote-install-model.ts +++ b/src/main/ssh/remote-install-model.ts @@ -1,11 +1,13 @@ /** - * The two things Orca installs into `~/.orca-remote/`, and the rules that keep them from + * The things Orca installs into `~/.orca-remote/`, and the rules that keep them from * touching each other. * - * `docs/design/shipping-orcad.html` §06 settles that on-disk coexistence is permanent: the - * relay is the dumb execution host for SSH-target users, orcad is the peer for paired - * environments, and no plan item retires either. So `relay-/` and `orcad-/` - * sit side by side forever, and the namespace has to be a parameter rather than a literal. + * Design D10 (to be tracked in docs/reference/remote-server-install-model.md): the relay and + * orcad converge into one server package; on the host that is `server-/` plus the + * shared `runtimes/node-/` store. Legacy `relay-*` / `orcad-*` directories belong to a + * migration sweep that deletes only on an `exited` verdict, handed over in two releases: this + * one lists them as diagnostics only. Until then each is its own namespace, so the prefix has + * to be a parameter rather than a literal. * * GC ownership is the trap that parameterization creates. Each model garbage-collects ONLY * its own directories — see `remoteInstallDirOwner`. Relay's regex happened to be narrow @@ -21,8 +23,10 @@ import { import { orcadArtifactFilenames, ORCAD_INSTALL_COMPLETE_FILENAME, + ORCAD_RUNTIMES_DIRNAME, ORCAD_VERSION_FILENAME } from '../../shared/orcad-artifacts' +import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' export type RemoteInstallModelId = 'relay' | 'orcad' @@ -35,7 +39,7 @@ export type RemoteInstallModel = { readonly versionFilename: string readonly installCompleteFilename: string /** Files whose absence means a torn install, so the probe forces a re-deploy. */ - requiredArtifacts(isWindows: boolean): string[] + requiredArtifacts(host: RemoteHostPlatform): string[] } export const RELAY_INSTALL_MODEL: RemoteInstallModel = { @@ -44,7 +48,7 @@ export const RELAY_INSTALL_MODEL: RemoteInstallModel = { nativeDepsPackageName: 'orca-relay', versionFilename: RELAY_VERSION_FILENAME, installCompleteFilename: RELAY_INSTALL_COMPLETE_FILENAME, - requiredArtifacts: (isWindows) => relayArtifactFilenames(isWindows) + requiredArtifacts: (host) => relayArtifactFilenames(isWindowsRemoteHost(host)) } export const ORCAD_INSTALL_MODEL: RemoteInstallModel = { @@ -53,7 +57,8 @@ export const ORCAD_INSTALL_MODEL: RemoteInstallModel = { nativeDepsPackageName: 'orca-orcad', versionFilename: ORCAD_VERSION_FILENAME, installCompleteFilename: ORCAD_INSTALL_COMPLETE_FILENAME, - requiredArtifacts: (isWindows) => orcadArtifactFilenames(isWindows ? 'win32' : '') + // libc changes no file name, so `-` names every slot file. + requiredArtifacts: (host) => orcadArtifactFilenames(`${host.os}-${host.arch}`) } export const REMOTE_INSTALL_MODELS: readonly RemoteInstallModel[] = [ @@ -102,15 +107,18 @@ export function remoteInstallListingRegexSource(model: RemoteInstallModel): stri } /** - * Which model owns a directory found in `~/.orca-remote/`, or null for anything neither - * model created. + * Which owner a directory found in `~/.orca-remote/` belongs to, or null for anything no + * owner created. * - * This is the answer to §06 falsifier 1's first half: **the model that created a directory - * owns it, and nothing else may delete it.** A relay GC pass that saw `orcad-0.1.0+abc` - * would be looking at the live install of a peer whose lifecycle it has no view into — the - * SSH-execution-boundary collapse in directory form. + * Design D10: **the model that created a directory owns it, and nothing else may delete it** + * (amended only in the release after the two-step hand-over). A relay GC pass that saw + * `orcad-0.1.0+abc` would be looking at the live install of a peer whose lifecycle it has no + * view into — the SSH-execution-boundary collapse in directory form. */ -export function remoteInstallDirOwner(dirName: string): RemoteInstallModelId | null { +export function remoteInstallDirOwner(dirName: string): RemoteInstallDirOwner | null { + if (dirName === ORCAD_RUNTIMES_DIRNAME) { + return 'runtimes' + } for (const model of REMOTE_INSTALL_MODELS) { if (new RegExp(remoteInstallListingRegexSource(model)).test(dirName)) { return model.id @@ -124,11 +132,17 @@ export function remoteInstallGcPermits(model: RemoteInstallModel, dirName: strin return remoteInstallDirOwner(dirName) === model.id } -export type RemoteInstallInventory = Record +/** + * `runtimes` is the shared Node store (design D5). No version-dir model owns it, so neither + * model's GC can list or delete it; only `remote-node-runtime-store-gc.ts` collects inside it. + */ +export type RemoteInstallDirOwner = RemoteInstallModelId | 'runtimes' + +export type RemoteInstallInventory = Record /** Group a raw `~/.orca-remote/` listing by owning model, for diagnostics and the client's choice. */ export function inventoryRemoteInstallDirs(dirNames: readonly string[]): RemoteInstallInventory { - const inventory: RemoteInstallInventory = { relay: [], orcad: [], unknown: [] } + const inventory: RemoteInstallInventory = { relay: [], orcad: [], runtimes: [], unknown: [] } for (const name of dirNames) { const owner = remoteInstallDirOwner(name) if (owner) { diff --git a/src/main/ssh/remote-install-previous-version.test.ts b/src/main/ssh/remote-install-previous-version.test.ts new file mode 100644 index 000000000000..e64fdb72edef --- /dev/null +++ b/src/main/ssh/remote-install-previous-version.test.ts @@ -0,0 +1,160 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as DeployHelpers from './ssh-relay-deploy-helpers' + +vi.mock('./ssh-relay-deploy-helpers', async (importOriginal) => ({ + ...(await importOriginal()), + execCommand: vi.fn() +})) + +import type { SshConnection } from './ssh-connection' +import { gcOldRelayVersions } from './remote-install-gc' +import { ORCAD_INSTALL_MODEL, RELAY_INSTALL_MODEL } from './remote-install-model' +import { + listCompletedInstallsNewestFirstCommand, + parseCompletedInstallsNewestFirst, + REMOTE_INSTALL_ORDER_OK +} from './remote-install-previous-version' +import { execCommand } from './ssh-relay-deploy-helpers' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: all connection access is replaced by execCommand's mock. +const conn = {} as SshConnection +const host = getRemoteHostPlatform('linux-x64') +const mockExec = vi.mocked(execCommand) + +describe('parseCompletedInstallsNewestFirst', () => { + it('keeps only this model’s version dirs, in host order', () => { + const output = [ + '/h/.orca-remote/relay-0.2.0+bbb/.install-complete', + '/h/.orca-remote/orcad-0.2.0+bbb/.install-complete', + '/h/.orca-remote/relay-0.1.0+aaa/.install-complete', + REMOTE_INSTALL_ORDER_OK + ].join('\n') + expect(parseCompletedInstallsNewestFirst(output, RELAY_INSTALL_MODEL)).toEqual([ + 'relay-0.2.0+bbb', + 'relay-0.1.0+aaa' + ]) + }) + + it('is null when the host did not finish the listing', () => { + expect( + parseCompletedInstallsNewestFirst( + '/h/.orca-remote/relay-0.1.0+aaa/.install-complete', + RELAY_INSTALL_MODEL + ) + ).toBeNull() + }) +}) + +const posixOnly = process.platform === 'win32' ? describe.skip : describe + +posixOnly('listCompletedInstallsNewestFirstCommand (real shell)', () => { + let base: string + beforeEach(() => { + base = mkdtempSync(join(tmpdir(), 'install-order-')) + }) + afterEach(() => { + rmSync(base, { recursive: true, force: true }) + }) + + function install(name: string, mtimeSeconds: number, complete = true): void { + mkdirSync(join(base, name)) + if (complete) { + const marker = join(base, name, '.install-complete') + writeFileSync(marker, '') + utimesSync(marker, mtimeSeconds, mtimeSeconds) + } + } + + function run(): string[] | null { + const out = execFileSync( + '/bin/sh', + ['-c', listCompletedInstallsNewestFirstCommand(host, base, RELAY_INSTALL_MODEL)], + { encoding: 'utf8' } + ) + return parseCompletedInstallsNewestFirst(out, RELAY_INSTALL_MODEL) + } + + it('orders completed installs by marker mtime and skips torn ones', () => { + install('relay-0.1.0+aaa', 1_000) + install('relay-0.3.0+ccc', 3_000) + install('relay-0.2.0+bbb', 2_000) + install('relay-0.4.0+ddd', 4_000, false) + install('orcad-0.9.0+eee', 9_000) + + expect(run()).toEqual(['relay-0.3.0+ccc', 'relay-0.2.0+bbb', 'relay-0.1.0+aaa']) + }) + + it('answers an empty order for a base without completed installs', () => { + expect(run()).toEqual([]) + }) + + it('is scoped to the model prefix', () => { + install('orcad-0.9.0+eee', 9_000) + const out = execFileSync( + '/bin/sh', + ['-c', listCompletedInstallsNewestFirstCommand(host, base, ORCAD_INSTALL_MODEL)], + { encoding: 'utf8' } + ) + expect(parseCompletedInstallsNewestFirst(out, ORCAD_INSTALL_MODEL)).toEqual(['orcad-0.9.0+eee']) + }) +}) + +describe('relay GC keeps the previous build', () => { + beforeEach(() => { + mockExec.mockReset() + mockExec.mockResolvedValue('') + }) + + it('never probes or removes the most recent other completed install', async () => { + mockExec + .mockResolvedValueOnce('relay-0.1.0+aaa\n') + .mockResolvedValueOnce( + [ + '/home/u/.orca-remote/relay-0.2.0+bbb/.install-complete', + '/home/u/.orca-remote/relay-0.1.0+aaa/.install-complete', + REMOTE_INSTALL_ORDER_OK + ].join('\n') + ) + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.2.0+bbb', host) + + expect(mockExec).toHaveBeenCalledTimes(2) + }) + + it('deletes nothing when the host cannot report install order', async () => { + mockExec + .mockResolvedValueOnce('relay-0.1.0+aaa\nrelay-0.0.9+zzz\n') + .mockRejectedValueOnce( + Object.assign(new Error('ls failed'), { sshChannelCloseConfirmed: true }) + ) + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.2.0+bbb', host) + + expect(mockExec).toHaveBeenCalledTimes(2) + }) + + it('keeps an old build whose liveness probe times out', async () => { + mockExec + .mockResolvedValueOnce('relay-0.1.0+aaa\n') + .mockResolvedValueOnce(`relay-0.1.5+fff\n${REMOTE_INSTALL_ORDER_OK}`) + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockRejectedValueOnce( + Object.assign(new Error('SSH command timed out'), { sshChannelCloseConfirmed: true }) + ) + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.2.0+bbb', host, { + nodePath: '/usr/bin/node' + }) + + const commands = mockExec.mock.calls.map(([, command]) => command) + expect(commands[4]).toContain('.relay-pid') + expect(commands.some((command) => command.includes('gc-claim'))).toBe(false) + expect(mockExec).toHaveBeenCalledTimes(5) + }) +}) diff --git a/src/main/ssh/remote-install-previous-version.ts b/src/main/ssh/remote-install-previous-version.ts new file mode 100644 index 000000000000..e9b2b92f6835 --- /dev/null +++ b/src/main/ssh/remote-install-previous-version.ts @@ -0,0 +1,106 @@ +/** + * The version dir a model ran before the current one, pinned against GC like orcad's + * rollback target (design D5 "plus the relay's previous version as a pin"). + * + * The relay keeps no activation record, so "previous" is the most recently completed install + * of the same model other than the current one, by `.install-complete` mtime. + */ +import type { SshConnection } from './ssh-connection' +import { shellEscape } from './ssh-connection-utils' +import { execCommand } from './ssh-relay-deploy-helpers' +import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' +import { + remoteInstallGcPermits, + remoteInstallVersionDirRegex, + type RemoteInstallModel +} from './remote-install-model' +import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' + +export const REMOTE_INSTALL_ORDER_OK = 'ORCA_INSTALL_ORDER_OK' + +export function listCompletedInstallsNewestFirstCommand( + host: RemoteHostPlatform, + baseDir: string, + model: RemoteInstallModel +): string { + const prefix = `${model.dirPrefix}-` + if (isWindowsRemoteHost(host)) { + return powerShellCommand( + [ + "$ErrorActionPreference = 'Stop'", + `$base = ${powerShellLiteral(baseDir)}`, + 'if (Test-Path -LiteralPath $base -PathType Container) {', + `Get-ChildItem -LiteralPath $base -Directory -Filter '${prefix}*' | ForEach-Object { ` + + `$marker = Join-Path $_.FullName ${powerShellLiteral(model.installCompleteFilename)}; ` + + 'if (Test-Path -LiteralPath $marker -PathType Leaf) { Get-Item -LiteralPath $marker } ' + + '} | Sort-Object LastWriteTimeUtc -Descending | ForEach-Object { $_.Directory.Name }', + '}', + `'${REMOTE_INSTALL_ORDER_OK}'` + ].join('\n') + ) + } + return [ + `base=${shellEscape(baseDir)}`, + `set -- "$base"/${prefix}*/${shellEscape(model.installCompleteFilename)}`, + `[ -e "$1" ] || { echo ${REMOTE_INSTALL_ORDER_OK}; exit 0; }`, + 'ls -1t -- "$@" || exit 1', + `echo ${REMOTE_INSTALL_ORDER_OK}` + ].join('\n') +} + +/** Dir names newest first, or null when the host could not answer. */ +export function parseCompletedInstallsNewestFirst( + output: string, + model: RemoteInstallModel +): string[] | null { + const lines = output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + if (!lines.includes(REMOTE_INSTALL_ORDER_OK)) { + return null + } + const versionDirRegex = remoteInstallVersionDirRegex(model) + const names: string[] = [] + for (const line of lines) { + // POSIX prints `//.install-complete`; PowerShell prints ``. + const segments = line.split(/[\\/]/) + const name = segments.length > 1 ? segments.at(-2) : segments[0] + if (name && versionDirRegex.test(name) && remoteInstallGcPermits(model, name)) { + names.push(name) + } + } + return names +} + +export type PreviousInstallResult = { state: 'ok'; dirName: string | null } | { state: 'unknown' } + +export async function findPreviousRemoteInstall( + conn: SshConnection, + host: RemoteHostPlatform, + baseDir: string, + model: RemoteInstallModel, + currentDirName: string +): Promise { + let output: string + try { + output = await execCommand( + conn, + listCompletedInstallsNewestFirstCommand(host, baseDir, model), + { + wrapCommand: host.commandDialect !== 'powershell' + } + ) + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + return { state: 'unknown' } + } + const ordered = parseCompletedInstallsNewestFirst(output, model) + if (!ordered) { + return { state: 'unknown' } + } + return { state: 'ok', dirName: ordered.find((name) => name !== currentDirName) ?? null } +} diff --git a/src/main/ssh/remote-node-runtime-store-gc.test.ts b/src/main/ssh/remote-node-runtime-store-gc.test.ts new file mode 100644 index 000000000000..c17cc8970679 --- /dev/null +++ b/src/main/ssh/remote-node-runtime-store-gc.test.ts @@ -0,0 +1,367 @@ +import { execFileSync, spawn, type ChildProcess } from 'node:child_process' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + utimesSync, + writeFileSync +} from 'node:fs' +import { once } from 'node:events' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as DeployHelpers from './ssh-relay-deploy-helpers' + +vi.mock('./ssh-relay-deploy-helpers', async (importOriginal) => ({ + ...(await importOriginal()), + execCommand: vi.fn() +})) + +import type { SshConnection } from './ssh-connection' +import { + gcRemoteNodeRuntimeStore, + parseSweptRuntimeStages, + planRuntimeStoreGc, + sweepStaleRuntimeStagesCommand +} from './remote-node-runtime-store-gc' +import { RUNTIME_STORE_LOCK_NAME } from './remote-node-runtime-store-lock' +import { + parseRuntimeStoreInventory, + RUNTIME_REF_NODE_PREFIX, + runtimeStoreInventoryCommand, + type RuntimeStoreInventory +} from './remote-node-runtime-store-inventory' +import { execCommand } from './ssh-relay-deploy-helpers' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: all connection access is replaced by execCommand's mock. +const conn = {} as SshConnection +const host = getRemoteHostPlatform('linux-x64') +const mockExec = vi.mocked(execCommand) +const sha = (c: string): string => c.repeat(64) + +function inventory(overrides: Partial = {}): RuntimeStoreInventory { + return { + entries: [], + verifiedNewestFirst: [], + referenced: new Set(), + held: new Set(), + processCheckRan: true, + dirNames: [], + ...overrides + } +} + +describe('planRuntimeStoreGc', () => { + const all = ['a', 'b', 'c', 'd'].map((c) => `node-${sha(c)}`) + + it('keeps the current pin and the newest other verified runtime', () => { + const plan = planRuntimeStoreGc( + inventory({ entries: all, verifiedNewestFirst: [all[0], all[1], all[2], all[3]] }), + [sha('b')] + ) + expect(plan.remove).toEqual([all[2], all[3]]) + expect(plan.kept).toEqual([all[0], all[1]]) + }) + + it('keeps referenced, process-held and unverified runtimes', () => { + const plan = planRuntimeStoreGc( + inventory({ + entries: all, + verifiedNewestFirst: [all[0], all[1], all[2]], + referenced: new Set([sha('b')]), + held: new Set([sha('c')]) + }), + [sha('a')] + ) + expect(plan.remove).toEqual([]) + }) + + it('keeps everything when no process check could run', () => { + const plan = planRuntimeStoreGc( + inventory({ entries: all, verifiedNewestFirst: all, processCheckRan: false }), + [sha('a')] + ) + expect(plan.remove).toEqual([]) + }) + + it('purges only abandoned tombstones of unwanted runtimes', () => { + const now = 10 * 60 * 60_000 + const old = `.gc-tombstone-node-${sha('c')}.7.${now - 31 * 60_000}` + const fresh = `.gc-tombstone-node-${sha('d')}.7.${now - 60_000}` + const referenced = `.gc-tombstone-node-${sha('e')}.7.${now - 31 * 60_000}` + const plan = planRuntimeStoreGc( + inventory({ entries: [old, fresh, referenced], referenced: new Set([sha('e')]) }), + [sha('a')], + now + ) + expect(plan.purgeTombstones).toEqual([old]) + }) +}) + +describe('parseRuntimeStoreInventory', () => { + it('rejects a partial listing, a reference error, and an unattributable reference', () => { + expect(parseRuntimeStoreInventory('ENTRY node-x')).toBeNull() + expect( + parseRuntimeStoreInventory('__ORCA_RUNTIME_STORE__REFS_ERR\n__ORCA_RUNTIME_STORE__OK') + ).toBeNull() + expect(parseRuntimeStoreInventory('REF garbage\n__ORCA_RUNTIME_STORE__OK')).toBeNull() + }) + + it('attributes process holds by runtime path, including renamed tombstones', () => { + const parsed = parseRuntimeStoreInventory( + [ + 'PROCESS_CHECK ps', + `HOLD /h/.orca-remote/runtimes/node-${sha('a')}/bin/node server.js`, + `HOLD /h/.orca-remote/runtimes/.gc-tombstone-node-${sha('b')}.1.2/bin/node`, + 'HOLD grep -F -- /h/.orca-remote/runtimes/', + '__ORCA_RUNTIME_STORE__OK' + ].join('\n') + ) + expect(parsed?.held).toEqual(new Set([sha('a'), sha('b')])) + }) +}) + +const posixOnly = process.platform === 'win32' ? describe.skip : describe + +posixOnly('gcRemoteNodeRuntimeStore (real shell)', () => { + let home: string + let root: string + let running: ChildProcess | null = null + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'runtime-store-')) + root = join(home, '.orca-remote') + mkdirSync(join(root, 'runtimes'), { recursive: true }) + mockExec.mockReset() + mockExec.mockImplementation(async (_conn, command) => + execFileSync('/bin/sh', ['-c', command], { encoding: 'utf8' }) + ) + }) + afterEach(() => { + running?.kill('SIGKILL') + running = null + rmSync(home, { recursive: true, force: true }) + }) + + // `b` is always verified last, so it is the previous pin the store keeps. + function runtime(c: string, verified = true): string { + const dir = join(root, 'runtimes', `node-${sha(c)}`) + mkdirSync(join(dir, 'bin'), { recursive: true }) + writeFileSync(join(dir, 'bin', 'node'), '#!/bin/sh\nsleep 30\n') + chmodSync(join(dir, 'bin', 'node'), 0o755) + if (verified) { + writeFileSync(join(dir, '.verified'), '') + const at = c === 'b' ? 2_000_000 : 1_000_000 + utimesSync(join(dir, '.verified'), at, at) + } + return dir + } + + function versionDir(name: string): string { + const dir = join(root, name) + mkdirSync(dir, { recursive: true }) + return dir + } + + it('removes only unreferenced, unpinned, idle, verified runtimes', async () => { + runtime('a') // current pin + runtime('c') // referenced by an orcad slot marker + runtime('d') // referenced by a relay ref file + runtime('e') // unreferenced: collected + const running_ = runtime('f') // executing + runtime('9', false) // mid-promotion + runtime('b') // newest other verified: the previous pin + writeFileSync(join(versionDir('orcad-0.1.0+aaa'), '.runtime-node'), `${sha('c')}\n`) + writeFileSync(join(versionDir('relay-0.1.0+aaa'), `${RUNTIME_REF_NODE_PREFIX}${sha('d')}`), '') + running = spawn(join(running_, 'bin', 'node'), [], { stdio: 'ignore' }) + await once(running, 'spawn') + + const result = await gcRemoteNodeRuntimeStore(conn, host, home, { currentPins: [sha('a')] }) + + expect(result).toMatchObject({ + state: 'collected', + removed: [`node-${sha('e')}`], + legacyDirs: expect.arrayContaining(['orcad-0.1.0+aaa', 'relay-0.1.0+aaa']) + }) + for (const kept of ['a', 'b', 'c', 'd', 'f', '9']) { + expect(existsSync(join(root, 'runtimes', `node-${sha(kept)}`))).toBe(true) + } + expect(existsSync(join(root, 'runtimes', `node-${sha('e')}`))).toBe(false) + // D10 two-step: legacy dirs are reported, never deleted by this pass. + expect(existsSync(join(root, 'orcad-0.1.0+aaa'))).toBe(true) + expect(existsSync(join(root, 'relay-0.1.0+aaa'))).toBe(true) + }) + + it('holds a runtime a process runs through another spelling of a symlinked home', async () => { + runtime('a') + runtime('b') + const held = runtime('e') + const alias = mkdtempSync(join(tmpdir(), 'runtime-store-alias-')) + rmSync(alias, { recursive: true }) + symlinkSync(home, alias) + try { + running = spawn(join(held, 'bin', 'node'), [], { stdio: 'ignore' }) + await once(running, 'spawn') + + const result = await gcRemoteNodeRuntimeStore(conn, host, alias, { currentPins: [sha('a')] }) + + expect(result).toMatchObject({ state: 'collected', removed: [] }) + expect(existsSync(join(held, 'bin', 'node'))).toBe(true) + } finally { + rmSync(alias, { force: true }) + } + }) + + it('keeps every runtime when a reference marker cannot be attributed', async () => { + runtime('a') + runtime('b') + runtime('e') + writeFileSync(join(versionDir('server-0.2.0+ccc'), '.runtime-node'), 'not-a-sha\n') + + const result = await gcRemoteNodeRuntimeStore(conn, host, home, { currentPins: [sha('a')] }) + + expect(result.state).toBe('skipped') + expect(existsSync(join(root, 'runtimes', `node-${sha('e')}`))).toBe(true) + }) + + it('restores a runtime that gained a reference after the rename', async () => { + runtime('a') + runtime('b') + runtime('e') + let inventories = 0 + mockExec.mockImplementation(async (_conn, command) => { + if (command.includes('__ORCA_RUNTIME_STORE__OK') && ++inventories === 2) { + writeFileSync(join(versionDir('orcad-0.3.0+ddd'), '.runtime-node'), `${sha('e')}\n`) + } + return execFileSync('/bin/sh', ['-c', command], { encoding: 'utf8' }) + }) + + const result = await gcRemoteNodeRuntimeStore(conn, host, home, { currentPins: [sha('a')] }) + + expect(result).toMatchObject({ state: 'collected', removed: [] }) + expect(existsSync(join(root, 'runtimes', `node-${sha('e')}`, 'bin', 'node'))).toBe(true) + }) + + it('skips the pass while an installer holds the store lock', async () => { + runtime('a') + runtime('b') + runtime('e') + mkdirSync(join(root, 'runtimes', RUNTIME_STORE_LOCK_NAME)) + + const result = await gcRemoteNodeRuntimeStore(conn, host, home, { currentPins: [sha('a')] }) + + expect(result.state).toBe('skipped') + expect(existsSync(join(root, 'runtimes', `node-${sha('e')}`))).toBe(true) + expect(existsSync(join(root, 'runtimes', RUNTIME_STORE_LOCK_NAME))).toBe(true) + }) + + it('recovers a lock past the 20-minute stale rule and releases it after the pass', async () => { + runtime('a') + runtime('b') + runtime('e') + const lock = join(root, 'runtimes', RUNTIME_STORE_LOCK_NAME) + mkdirSync(lock) + const old = Date.now() / 1000 - 21 * 60 + utimesSync(lock, old, old) + + const result = await gcRemoteNodeRuntimeStore(conn, host, home, { currentPins: [sha('a')] }) + + expect(result).toMatchObject({ state: 'collected', removed: [`node-${sha('e')}`] }) + expect(existsSync(lock)).toBe(false) + }) + + it('keeps the store lock when the pass ends in an unconfirmed termination', async () => { + runtime('a') + const lost = Object.assign(new Error('lost'), { sshChannelCloseConfirmed: false }) + mockExec.mockImplementation(async (_conn, command) => { + if (command.includes('__ORCA_RUNTIME_STORE__OK')) { + throw lost + } + return execFileSync('/bin/sh', ['-c', command], { encoding: 'utf8' }) + }) + + await expect( + gcRemoteNodeRuntimeStore(conn, host, home, { currentPins: [sha('a')] }) + ).rejects.toBe(lost) + expect(existsSync(join(root, 'runtimes', RUNTIME_STORE_LOCK_NAME))).toBe(true) + }) + + it('sweeps only upload stages nothing has written to within the stale rule', async () => { + runtime('a') + const old = Date.now() / 1000 - 21 * 60 + const stage = (name: string, fileAge: number, dirAge: number): string => { + const dir = join(root, 'runtimes', name) + mkdirSync(dir) + writeFileSync(join(dir, 'node.tar.gz'), 'x') + utimesSync(join(dir, 'node.tar.gz'), fileAge, fileAge) + utimesSync(dir, dirAge, dirAge) + return dir + } + const now = Date.now() / 1000 + const abandoned = stage(`.stage-node-${sha('c')}-1`, old, old) + const uploading = stage(`.stage-node-${sha('c')}-2`, now, old) + const fresh = stage(`.stage-node-${sha('c')}-3`, now, now) + + const result = await gcRemoteNodeRuntimeStore(conn, host, home, { currentPins: [sha('a')] }) + + expect(result).toMatchObject({ sweptStages: [`.stage-node-${sha('c')}-1`] }) + expect(existsSync(abandoned)).toBe(false) + expect(existsSync(uploading)).toBe(true) + expect(existsSync(fresh)).toBe(true) + }) + + it('keeps a stage when find cannot answer', () => { + const dir = join(root, 'runtimes', '.stage-node-x-1') + mkdirSync(dir) + const old = Date.now() / 1000 - 21 * 60 + utimesSync(dir, old, old) + const out = execFileSync( + '/bin/sh', + ['-c', `find() { return 1; }\n${sweepStaleRuntimeStagesCommand(join(root, 'runtimes'))}`], + { encoding: 'utf8' } + ) + expect(parseSweptRuntimeStages(out)).toEqual([]) + expect(existsSync(dir)).toBe(true) + }) + + it('is inert when the store does not exist', async () => { + rmSync(join(root, 'runtimes'), { recursive: true }) + const out = execFileSync('/bin/sh', ['-c', runtimeStoreInventoryCommand(host, home)], { + encoding: 'utf8' + }) + expect(parseRuntimeStoreInventory(out)?.entries).toEqual([]) + }) +}) + +describe('gcRemoteNodeRuntimeStore termination', () => { + beforeEach(() => { + mockExec.mockReset() + }) + + it('rethrows an unconfirmed inventory termination', async () => { + const error = Object.assign(new Error('SSH command timed out'), { + sshChannelCloseConfirmed: false + }) + mockExec.mockRejectedValueOnce(error) + await expect( + gcRemoteNodeRuntimeStore(conn, host, '/home/u', { currentPins: [sha('a')] }) + ).rejects.toBe(error) + }) + + it('skips Windows hosts without running anything', async () => { + const result = await gcRemoteNodeRuntimeStore( + conn, + getRemoteHostPlatform('win32-x64'), + 'C:/Users/u', + { + currentPins: [sha('a')] + } + ) + expect(result.state).toBe('skipped') + expect(mockExec).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ssh/remote-node-runtime-store-gc.ts b/src/main/ssh/remote-node-runtime-store-gc.ts new file mode 100644 index 000000000000..f0b26be16ae1 --- /dev/null +++ b/src/main/ssh/remote-node-runtime-store-gc.ts @@ -0,0 +1,319 @@ +/** + * Collects the shared `~/.orca-remote/runtimes/node-/` store (design D5 GC). + * + * A runtime is deleted only when all of these hold: no retained directory references it + * (`.runtime-node` or `.runtime-ref-node-`), it is neither a pin this client runs nor the + * newest other verified runtime (keep two), and a process check ran and found nothing executing + * from it. Process evidence can only add holds; a scan that could not run keeps everything. + * + * Legacy `relay-*` / `orcad-*` directories are read for references and reported as + * diagnostics, never deleted here (design D10 two-step hand-over). + * + * The pass runs only while it holds the store lock that promotion takes, so a runtime cannot be + * published and collected at once; a busy lock skips the pass rather than waiting. + */ +import { randomInt } from 'node:crypto' +import type { SshConnection } from './ssh-connection' +import { RUNTIME_STORE_STAGE_PREFIX } from './orcad-remote-node-runtime' +import { inventoryRemoteInstallDirs } from './remote-install-model' +import { + parseRuntimeStoreInventory, + RUNTIME_STORE_ENTRY_NAME, + RUNTIME_STORE_TOMBSTONE_NAME, + RUNTIME_STORE_TOMBSTONE_PREFIX, + runtimeStoreInventoryCommand, + type RuntimeStoreInventory +} from './remote-node-runtime-store-inventory' +import { + remoteNodeRuntimeStoreDir, + tryWithRuntimeStoreLock +} from './remote-node-runtime-store-lock' +import { shellEscape } from './ssh-connection-utils' +import { execCommand } from './ssh-relay-deploy-helpers' +import { INSTALL_LOCK_STALE_MS } from './ssh-relay-install-lock' +import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' +import { + moveRemoteTreeCommand, + removeRemoteTreeCommand, + restoreRemoteTreeCommand +} from './ssh-remote-commands' +import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' + +const MAX_REMOVALS_PER_PASS = 8 +const ABANDONED_TOMBSTONE_MS = 30 * 60_000 + +export type RuntimeStoreGcPlan = { + /** `node-` entries to rename away and delete. */ + remove: string[] + /** Abandoned tombstones whose runtime is still unwanted. */ + purgeTombstones: string[] + kept: string[] +} + +/** Why a sha must stay, or null when nothing holds it. */ +function holdReason( + sha: string, + inventory: RuntimeStoreInventory, + pins: ReadonlySet +): string | null { + if (!inventory.processCheckRan) { + return 'process check unavailable' + } + if (pins.has(sha)) { + return 'pinned' + } + if (inventory.referenced.has(sha)) { + return 'referenced' + } + if (inventory.held.has(sha)) { + return 'in use by a process' + } + return null +} + +export function planRuntimeStoreGc( + inventory: RuntimeStoreInventory, + currentPins: readonly string[], + now: number = Date.now() +): RuntimeStoreGcPlan { + const pins = new Set(currentPins) + // Keep two: the pin this client runs and the newest other verified runtime (the previous pin). + const previous = inventory.verifiedNewestFirst + .map((name) => RUNTIME_STORE_ENTRY_NAME.exec(name)?.[1]) + .find((sha): sha is string => !!sha && !pins.has(sha)) + if (previous) { + pins.add(previous) + } + const verified = new Set(inventory.verifiedNewestFirst) + const plan: RuntimeStoreGcPlan = { remove: [], purgeTombstones: [], kept: [] } + for (const name of inventory.entries) { + const entry = RUNTIME_STORE_ENTRY_NAME.exec(name) + if (entry) { + // Unverified means mid-promotion or torn; the installer, not GC, owns that state. + const idle = verified.has(name) && holdReason(entry[1], inventory, pins) === null + if (idle && plan.remove.length < MAX_REMOVALS_PER_PASS) { + plan.remove.push(name) + } else { + plan.kept.push(name) + } + continue + } + const tombstone = RUNTIME_STORE_TOMBSTONE_NAME.exec(name) + if ( + tombstone && + now - Number(tombstone[2]) >= ABANDONED_TOMBSTONE_MS && + holdReason(tombstone[1], inventory, pins) === null + ) { + plan.purgeTombstones.push(name) + } + } + return plan +} + +export type RuntimeStoreGcResult = + | { state: 'skipped'; reason: string } + | { + state: 'collected' + removed: string[] + kept: string[] + legacyDirs: string[] + sweptStages: string[] + } + +const SWEPT_STAGE = 'SWEPT' + +/** + * Removes upload stages nothing has written to within the install lock's stale rule. Why file + * mtimes and not the directory's: an upload in flight keeps rewriting its archive, not the dir. + * A `find` that cannot answer keeps the stage. + */ +export function sweepStaleRuntimeStagesCommand(storeDir: string): string { + const staleMinutes = Math.ceil(INSTALL_LOCK_STALE_MS / 60_000) + return [ + `for s in ${shellEscape(storeDir)}/${RUNTIME_STORE_STAGE_PREFIX}*; do`, + ' [ -d "$s" ] && [ ! -L "$s" ] || continue', + ` recent=$(find "$s" -mmin -${staleMinutes} -print 2>/dev/null) || continue`, + ' [ -z "$recent" ] || continue', + ` rm -rf -- "$s" && printf '${SWEPT_STAGE} %s\n' "\${s##*/}"`, + 'done', + 'true' + ].join('\n') +} + +export function parseSweptRuntimeStages(output: string): string[] { + return output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.startsWith(`${SWEPT_STAGE} `)) + .map((line) => line.slice(SWEPT_STAGE.length + 1)) +} + +function exec(conn: SshConnection, command: string, signal?: AbortSignal): Promise { + return execCommand(conn, command, { wrapCommand: true, signal }) +} + +async function readInventory( + conn: SshConnection, + host: RemoteHostPlatform, + remoteHome: string, + signal?: AbortSignal +): Promise { + try { + return parseRuntimeStoreInventory( + await exec(conn, runtimeStoreInventoryCommand(host, remoteHome), signal) + ) + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + return null + } +} + +/** + * One pass over the runtime store. Confirmed failures keep the runtime and end quietly; an + * unconfirmed SSH termination is rethrown so the caller stops its cleanup chain. + */ +export async function gcRemoteNodeRuntimeStore( + conn: SshConnection, + host: RemoteHostPlatform, + remoteHome: string, + options: { currentPins: readonly string[]; signal?: AbortSignal } +): Promise { + if (isWindowsRemoteHost(host)) { + return { state: 'skipped', reason: 'Windows hosts have no managed runtime store yet' } + } + const store = remoteNodeRuntimeStoreDir(host, remoteHome) + const locked = await tryWithRuntimeStoreLock( + conn, + host, + store, + () => collectHoldingStoreLock(conn, host, remoteHome, store, options), + options.signal + ) + return locked?.value ?? { state: 'skipped', reason: 'runtime store lock is held or absent' } +} + +async function collectHoldingStoreLock( + conn: SshConnection, + host: RemoteHostPlatform, + remoteHome: string, + store: string, + options: { currentPins: readonly string[]; signal?: AbortSignal } +): Promise { + const sweptStages = await exec(conn, sweepStaleRuntimeStagesCommand(store), options.signal) + .then(parseSweptRuntimeStages) + .catch((error: unknown) => { + if (isUnconfirmedSshCommandTermination(error)) { + throw error + } + return [] + }) + const inventory = await readInventory(conn, host, remoteHome, options.signal) + if (!inventory) { + return { state: 'skipped', reason: 'runtime store inventory was unverifiable' } + } + const legacy = inventoryRemoteInstallDirs(inventory.dirNames) + const legacyDirs = [...legacy.relay, ...legacy.orcad] + const plan = planRuntimeStoreGc(inventory, options.currentPins) + const removed: string[] = [] + const kept = [...plan.kept] + for (const name of plan.purgeTombstones) { + if (await removeTree(conn, host, joinRemotePath(host, store, name), options.signal)) { + removed.push(name) + } + } + for (const name of plan.remove) { + const sha = RUNTIME_STORE_ENTRY_NAME.exec(name)?.[1] ?? '' + const entryDir = joinRemotePath(host, store, name) + const tombstone = joinRemotePath( + host, + store, + `${RUNTIME_STORE_TOMBSTONE_PREFIX}${name}.${randomInt(1, 2 ** 47)}.${Date.now()}` + ) + if (!(await moveTree(conn, host, entryDir, tombstone, options.signal))) { + kept.push(name) + continue + } + // Why recheck after the rename: an installer that saw this runtime present may be writing + // its reference now; restoring is the only outcome that leaves its slot launchable. + const recheck = await readInventory(conn, host, remoteHome, options.signal).catch( + async (err: unknown) => { + await restoreTree(conn, host, tombstone, entryDir, options.signal).catch(() => {}) + throw err + } + ) + if (!recheck || holdReason(sha, recheck, new Set(options.currentPins)) !== null) { + await restoreTree(conn, host, tombstone, entryDir, options.signal) + kept.push(name) + continue + } + if (await removeTree(conn, host, tombstone, options.signal)) { + removed.push(name) + } else { + kept.push(name) + } + } + if (removed.length > 0) { + const legacyNote = + legacyDirs.length > 0 + ? `; legacy install dirs left for the migration sweep: ${legacyDirs.join(', ')}` + : '' + console.log(`[runtime-store] GC: removed ${removed.join(', ')}${legacyNote}`) + } + if (sweptStages.length > 0) { + console.log(`[runtime-store] GC: swept stale upload stages ${sweptStages.join(', ')}`) + } + return { state: 'collected', removed, kept, legacyDirs, sweptStages } +} + +async function moveTree( + conn: SshConnection, + host: RemoteHostPlatform, + source: string, + destination: string, + signal?: AbortSignal +): Promise { + try { + return ( + (await exec(conn, moveRemoteTreeCommand(host, source, destination), signal)).trim() === + 'MOVED' + ) + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + return false + } +} + +async function restoreTree( + conn: SshConnection, + host: RemoteHostPlatform, + tombstone: string, + entryDir: string, + signal?: AbortSignal +): Promise { + await exec(conn, restoreRemoteTreeCommand(host, tombstone, entryDir), signal).catch((err) => { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + }) +} + +async function removeTree( + conn: SshConnection, + host: RemoteHostPlatform, + path: string, + signal?: AbortSignal +): Promise { + try { + await exec(conn, removeRemoteTreeCommand(host, path), signal) + return true + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + return false + } +} diff --git a/src/main/ssh/remote-node-runtime-store-inventory.ts b/src/main/ssh/remote-node-runtime-store-inventory.ts new file mode 100644 index 000000000000..583ad2301395 --- /dev/null +++ b/src/main/ssh/remote-node-runtime-store-inventory.ts @@ -0,0 +1,140 @@ +/** + * One read-only pass over `~/.orca-remote/` answering what the runtime store GC needs: store + * entries, which runtimes are referenced, verified order, and which a running process holds. + */ +import { + ORCAD_NODE_RUNTIME_DIR_PREFIX, + ORCAD_NODE_RUNTIME_MARKER_FILENAME, + ORCAD_RUNTIMES_DIRNAME +} from '../../shared/orcad-artifacts' +import { shellEscape } from './ssh-connection-utils' +import { RELAY_REMOTE_DIR } from './relay-protocol' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' + +/** A version dir names a runtime it needs with an empty file of this prefix + sha (design D5). */ +export const RUNTIME_REF_NODE_PREFIX = '.runtime-ref-node-' +export const RUNTIME_STORE_TOMBSTONE_PREFIX = '.gc-tombstone-' +const INVENTORY_OK = '__ORCA_RUNTIME_STORE__OK' +const REFS_ERR = '__ORCA_RUNTIME_STORE__REFS_ERR' +const MAX_DIRS = 512 +const SHA256 = /^[0-9a-f]{64}$/ +export const RUNTIME_STORE_ENTRY_NAME = new RegExp( + `^${ORCAD_NODE_RUNTIME_DIR_PREFIX}([0-9a-f]{64})$` +) +export const RUNTIME_STORE_TOMBSTONE_NAME = new RegExp( + `^${RUNTIME_STORE_TOMBSTONE_PREFIX.replace(/\./g, '\\.')}${ORCAD_NODE_RUNTIME_DIR_PREFIX}([0-9a-f]{64})\\.[0-9]+\\.([0-9]+)$` +) +const HELD_PATH = new RegExp( + `/${ORCAD_RUNTIMES_DIRNAME}/(?:${RUNTIME_STORE_TOMBSTONE_PREFIX.replace(/\./g, '\\.')})?${ORCAD_NODE_RUNTIME_DIR_PREFIX}([0-9a-f]{64})[./]` +) + +export type RuntimeStoreInventory = { + /** Store entry names: `node-` and this GC's tombstones. */ + entries: string[] + /** `node-` names with `.verified`, newest first. */ + verifiedNewestFirst: string[] + referenced: Set + held: Set + /** False when neither `ps` nor `/proc` answered; nothing may then be called idle. */ + processCheckRan: boolean + /** Other `~/.orca-remote/` directory names, for legacy diagnostics. */ + dirNames: string[] +} + +export function runtimeStoreInventoryCommand(host: RemoteHostPlatform, remoteHome: string): string { + const root = joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR) + const refPrefix = RUNTIME_REF_NODE_PREFIX + return [ + `root=${shellEscape(root)}`, + `rt="$root"/${ORCAD_RUNTIMES_DIRNAME}`, + `[ -d "$rt" ] || { printf '%s\\n' ${INVENTORY_OK}; exit 0; }`, + `[ -r "$root" ] && [ -x "$root" ] || { printf '%s\\n' ${REFS_ERR}; exit 0; }`, + 'n=0', + // Why every sibling and not a prefix: a directory this client does not recognise may still + // be a newer Orca's install that names a runtime. + 'for d in "$root"/* "$root"/.[!.]*; do', + ' [ -d "$d" ] || continue', + ' name=${d##*/}', + ` [ "$name" = ${ORCAD_RUNTIMES_DIRNAME} ] && continue`, + ` [ -r "$d" ] && [ -x "$d" ] || { printf '%s\\n' ${REFS_ERR}; exit 0; }`, + ' n=$((n+1))', + ` [ "$n" -le ${MAX_DIRS} ] || { printf '%s\\n' ${REFS_ERR}; exit 0; }`, + ` printf 'DIR %s\\n' "$name"`, + ` if [ -e "$d"/${ORCAD_NODE_RUNTIME_MARKER_FILENAME} ]; then`, + ` sha=$(cat "$d"/${ORCAD_NODE_RUNTIME_MARKER_FILENAME}) || { printf '%s\\n' ${REFS_ERR}; exit 0; }`, + ` printf 'REF %s\\n' "$sha"`, + ' fi', + ` for f in "$d"/${refPrefix}*; do`, + ' [ -e "$f" ] || continue', + ` printf 'REF %s\\n' "\${f##*/${refPrefix}}"`, + ' done', + 'done', + `for e in "$rt"/${ORCAD_NODE_RUNTIME_DIR_PREFIX}* "$rt"/${RUNTIME_STORE_TOMBSTONE_PREFIX}${ORCAD_NODE_RUNTIME_DIR_PREFIX}*; do`, + ` [ -d "$e" ] && printf 'ENTRY %s\\n' "\${e##*/}"`, + 'done', + `set -- "$rt"/${ORCAD_NODE_RUNTIME_DIR_PREFIX}*/.verified`, + 'if [ -e "$1" ]; then', + ` order=$(ls -1t -- "$@") || { printf '%s\\n' ${REFS_ERR}; exit 0; }`, + ` printf '%s\\n' "$order" | while IFS= read -r v; do v=\${v%/.verified}; printf 'VERIFIED %s\\n' "\${v##*/}"; done`, + 'fi', + // Process checks only add holds, so an unmatched `grep` is not an error. Why not "$rt/": + // /proc exe and argv may name the store through another spelling of a symlinked home. + 'if ps_out=$(ps -e -o args= 2>/dev/null); then', + " printf 'PROCESS_CHECK ps\\n'", + ` printf '%s\\n' "$ps_out" | grep -F -- /${ORCAD_RUNTIMES_DIRNAME}/ | sed 's/^/HOLD /'`, + 'fi', + 'if [ -n "$(readlink /proc/self/exe 2>/dev/null)" ]; then', + " printf 'PROCESS_CHECK proc\\n'", + ' for p in /proc/[0-9]*/exe; do', + ' t=$(readlink "$p" 2>/dev/null) || continue', + ` case "$t" in */${ORCAD_RUNTIMES_DIRNAME}/*) printf 'HOLD %s\\n' "$t";; esac`, + ' done', + 'fi', + `printf '%s\\n' ${INVENTORY_OK}` + ].join('\n') +} + +/** Null when the host could not produce a complete inventory; that keeps every runtime. */ +export function parseRuntimeStoreInventory(output: string): RuntimeStoreInventory | null { + const lines = output.split(/\r?\n/).map((line) => line.trim()) + if (!lines.includes(INVENTORY_OK) || lines.includes(REFS_ERR)) { + return null + } + const inventory: RuntimeStoreInventory = { + entries: [], + verifiedNewestFirst: [], + referenced: new Set(), + held: new Set(), + processCheckRan: false, + dirNames: [] + } + for (const line of lines) { + const space = line.indexOf(' ') + const tag = space === -1 ? line : line.slice(0, space) + const value = space === -1 ? '' : line.slice(space + 1).trim() + if (tag === 'REF') { + // An unattributable reference could name any runtime, so it stops the pass. + if (!SHA256.test(value)) { + return null + } + inventory.referenced.add(value) + } else if ( + tag === 'ENTRY' && + (RUNTIME_STORE_ENTRY_NAME.test(value) || RUNTIME_STORE_TOMBSTONE_NAME.test(value)) + ) { + inventory.entries.push(value) + } else if (tag === 'VERIFIED' && RUNTIME_STORE_ENTRY_NAME.test(value)) { + inventory.verifiedNewestFirst.push(value) + } else if (tag === 'HOLD') { + const sha = HELD_PATH.exec(value)?.[1] + if (sha) { + inventory.held.add(sha) + } + } else if (tag === 'PROCESS_CHECK') { + inventory.processCheckRan = true + } else if (tag === 'DIR' && value) { + inventory.dirNames.push(value) + } + } + return inventory +} diff --git a/src/main/ssh/remote-node-runtime-store-lock.ts b/src/main/ssh/remote-node-runtime-store-lock.ts new file mode 100644 index 000000000000..c0d923872a84 --- /dev/null +++ b/src/main/ssh/remote-node-runtime-store-lock.ts @@ -0,0 +1,117 @@ +/** + * The `runtimes/` store's mkdir lock (design D5). Promotion waits for it; store GC only tries it, + * so a collector never queues behind an install. Both use the install lock's primitives and its + * 20-minute stale rule. + */ +import { ORCAD_RUNTIMES_DIRNAME } from '../../shared/orcad-artifacts' +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' +import { acquireInstallLock, INSTALL_LOCK_STALE_SECONDS } from './ssh-relay-install-lock' +import { + tryCreateInstallLockCommand, + tryStealInstallLockCommand +} from './ssh-relay-install-lock-commands' +import { RELAY_REMOTE_DIR } from './relay-protocol' +import { removeRemoteTreeCommand } from './ssh-remote-commands' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' + +export const RUNTIME_STORE_LOCK_NAME = '.store-lock' + +export function remoteNodeRuntimeStoreDir(host: RemoteHostPlatform, remoteHome: string): string { + return joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR, ORCAD_RUNTIMES_DIRNAME) +} + +function lockDir(host: RemoteHostPlatform, storeDir: string): string { + return joinRemotePath(host, storeDir, RUNTIME_STORE_LOCK_NAME) +} + +async function releaseRuntimeStoreLock( + conn: SshConnection, + host: RemoteHostPlatform, + storeDir: string +): Promise { + await execCommand(conn, removeRemoteTreeCommand(host, lockDir(host, storeDir))).catch((error) => { + if (isUnconfirmedSshCommandTermination(error)) { + throw error + } + }) +} + +/** One attempt, stealing only a stale or previous-boot lock. A missing store is simply not acquired. */ +async function tryAcquireRuntimeStoreLock( + conn: SshConnection, + host: RemoteHostPlatform, + storeDir: string, + signal?: AbortSignal +): Promise { + const lock = lockDir(host, storeDir) + try { + const created = await execCommand(conn, tryCreateInstallLockCommand(host, lock), { signal }) + if (created.trim().endsWith('OK')) { + return true + } + const stolen = await execCommand( + conn, + tryStealInstallLockCommand(host, lock, INSTALL_LOCK_STALE_SECONDS), + { signal } + ) + return stolen.trim().endsWith('OK') + } catch (error) { + if (isUnconfirmedSshCommandTermination(error)) { + throw error + } + signal?.throwIfAborted() + return false + } +} + +async function runHoldingLock( + conn: SshConnection, + host: RemoteHostPlatform, + storeDir: string, + task: () => Promise +): Promise { + let value: T + try { + value = await task() + } catch (error) { + // Why keep it on an unconfirmed termination: the remote step may still be running. + if (!isUnconfirmedSshCommandTermination(error)) { + await releaseRuntimeStoreLock(conn, host, storeDir) + } + throw error + } + await releaseRuntimeStoreLock(conn, host, storeDir) + return value +} + +/** Runs `task` holding the store lock, waiting for it within the deploy bound. */ +export async function withRuntimeStoreLock( + conn: SshConnection, + host: RemoteHostPlatform, + storeDir: string, + task: () => Promise, + signal?: AbortSignal +): Promise { + await acquireInstallLock(conn, storeDir, host, { + signal, + lockName: RUNTIME_STORE_LOCK_NAME, + relayGcClaim: false + }) + return runHoldingLock(conn, host, storeDir, task) +} + +/** Runs `task` only when the lock is free now; null when another holder has it. */ +export async function tryWithRuntimeStoreLock( + conn: SshConnection, + host: RemoteHostPlatform, + storeDir: string, + task: () => Promise, + signal?: AbortSignal +): Promise<{ value: T } | null> { + if (!(await tryAcquireRuntimeStoreLock(conn, host, storeDir, signal))) { + return null + } + return { value: await runHoldingLock(conn, host, storeDir, task) } +} diff --git a/src/main/ssh/runtime-archive-download.ts b/src/main/ssh/runtime-archive-download.ts new file mode 100644 index 000000000000..869dc10b33ef --- /dev/null +++ b/src/main/ssh/runtime-archive-download.ts @@ -0,0 +1,132 @@ +/** Streams and hash-checks a pinned runtime archive, and extracts it with the host's own tools. */ +import { createHash } from 'node:crypto' +import { open } from 'node:fs/promises' +import { runProcess, type ProcessResult } from '../../shared/child-process/run-process' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { getZipExtractorCommand } from '../../shared/zip-extractor-command' +import type { MainHttpClient } from '../network/http-client' +import type { PinnedRuntimeArchive, PinnedRuntimeExecutable } from './pinned-runtime-materializer' + +const MAX_RUNTIME_ARCHIVE_BYTES = 200 * 1024 * 1024 + +/** + * Why the extractor needs a message of its own: `unzip` is absent from a minimal POSIX install, + * and a bare `spawn unzip ENOENT` names neither the missing tool nor the override. A misconfigured + * `ORCA_UNZIP_BIN` whose parent is a file reports ENOTDIR instead, which is the same verdict. + * + * The errno is the program path's, not the caller's: `runProcess` leaves cwd unset, so the child + * inherits the parent's without resolving it. Measured on macOS, Linux and Windows — spawn still + * succeeds from a deleted cwd, even though `process.cwd()` itself throws ENOENT there. + */ +export async function extractRuntimeArchive( + runtime: Pick, + archivePath: string, + extractDir: string, + signal?: AbortSignal +): Promise { + const { label } = runtime + // Why tar for .tar.gz: every POSIX host and Windows 10+ ships it; Node publishes no zip there. + const command = archivePath.endsWith('.zip') + ? getZipExtractorCommand(archivePath, extractDir) + : { + file: 'tar', + args: ['-xzf', archivePath, '-C', extractDir, runtime.member], + label: 'tar' + } + try { + return await runProcess({ + program: command.file, + args: command.args, + timeoutMs: 120_000, + signal + }) + } catch (error) { + if (isDefinitiveAbsence(error)) { + const override = archivePath.endsWith('.zip') + ? ', or set ORCA_UNZIP_BIN to an unzip-compatible extractor.' + : '.' + throw new Error( + `${label} archive extraction could not run ${command.file}: install ${command.label}${override}`, + { cause: error } + ) + } + throw error + } +} + +export async function downloadVerifiedArchive( + archive: PinnedRuntimeArchive, + destination: string, + fetcher: MainHttpClient['fetch'], + signal?: AbortSignal +): Promise { + const { label, url, archiveSha256: expectedSha256 } = archive + const stall = new AbortController() + const downloadSignal = signal ? AbortSignal.any([signal, stall.signal]) : stall.signal + const stallTimer = setTimeout(() => stall.abort(new Error(`${label} download stalled`)), 120_000) + try { + const response = await fetcher(url, { redirect: 'follow', signal: downloadSignal }) + if (!response.ok || !response.body) { + await response.body?.cancel().catch(() => undefined) + throw new Error(`${label} download failed: ${response.status} ${response.statusText}`) + } + const declaredLength = Number(response.headers.get('content-length')) + if (Number.isFinite(declaredLength) && declaredLength > MAX_RUNTIME_ARCHIVE_BYTES) { + await response.body.cancel().catch(() => undefined) + throw new Error(`${label} download exceeded the archive size limit`) + } + const handle = await open(destination, 'wx', 0o600).catch(async (error) => { + await response.body?.cancel().catch(() => undefined) + throw error + }) + const reader = response.body.getReader() + const hash = createHash('sha256') + let total = 0 + try { + for (;;) { + downloadSignal.throwIfAborted() + const chunk = await waitForPromiseWithSignal(reader.read(), downloadSignal) + if (chunk.done) { + break + } + if (chunk.value.byteLength > 0) { + stallTimer.refresh() + } + total += chunk.value.byteLength + if (total > MAX_RUNTIME_ARCHIVE_BYTES) { + throw new Error(`${label} download exceeded the archive size limit`) + } + hash.update(chunk.value) + await writeAll(handle, chunk.value) + } + } catch (error) { + await reader.cancel().catch(() => undefined) + throw error + } finally { + await handle.close() + } + const actual = hash.digest('hex') + if (actual !== expectedSha256) { + throw new Error( + `${label} archive checksum mismatch: expected ${expectedSha256}, got ${actual}` + ) + } + } finally { + clearTimeout(stallTimer) + } +} + +async function writeAll( + handle: Awaited>, + bytes: Uint8Array +): Promise { + let offset = 0 + while (offset < bytes.byteLength) { + const { bytesWritten } = await handle.write(bytes, offset, bytes.byteLength - offset) + if (bytesWritten === 0) { + throw new Error('Runtime archive write made no progress') + } + offset += bytesWritten + } +} diff --git a/src/main/ssh/sftp-stream-late-error.test.ts b/src/main/ssh/sftp-stream-late-error.test.ts index 4ed3bcf8b32f..a05dc3bb1747 100644 --- a/src/main/ssh/sftp-stream-late-error.test.ts +++ b/src/main/ssh/sftp-stream-late-error.test.ts @@ -150,11 +150,12 @@ describe('sandboxed SFTP subsystem diagnosis', () => { writeFile: () => Promise.reject(sftpNoSuchFileError()) } as unknown as SshConnection + // Windows hosts have no exec-stdin fallback; POSIX hosts stream instead (exec-fallback tests). await expect( writeRelayFile( conn, - getRemoteHostPlatform('linux-x64'), - '/home/user/.orca-remote/relay-1/.version', + getRemoteHostPlatform('win32-x64'), + 'C:/Users/user/.orca-remote/relay-1/.version', 'v1' ) ).rejects.toThrow(/SFTP subsystem sees a different filesystem/) diff --git a/src/main/ssh/sftp-stream-late-error.ts b/src/main/ssh/sftp-stream-late-error.ts index 1630668f874e..5ccdab3963d4 100644 --- a/src/main/ssh/sftp-stream-late-error.ts +++ b/src/main/ssh/sftp-stream-late-error.ts @@ -87,10 +87,9 @@ export function isSandboxedSftpNamespaceError(error: unknown): boolean { } /** - * SFTP is not optional for a bundled-ssh2 relay install — `SshConnection.sftp()` is the - * only transfer route on that transport, and the exec-based `tar`/`cat` transfers are - * bound to the system-SSH transport, not selectable per operation. So a sandboxed SFTP - * subsystem is a clean failure with an actionable message, not a degraded mode. + * POSIX hosts fall back to exec-channel stdin when SFTP is sandboxed (ssh-relay-install-transfers). + * A Windows host over bundled ssh2 has no such fallback, so there it is a clean failure with an + * actionable message, not a degraded mode. */ export function describeSandboxedSftpFailure(error: unknown, remotePath: string): Error { const detail = error instanceof Error ? error.message : String(error) diff --git a/src/main/ssh/ssh-connection-store.test.ts b/src/main/ssh/ssh-connection-store.test.ts index b086a9c5e310..24845d2d3736 100644 --- a/src/main/ssh/ssh-connection-store.test.ts +++ b/src/main/ssh/ssh-connection-store.test.ts @@ -89,11 +89,6 @@ describe('SshConnectionStore', () => { sshConfigHostsToTargetsMock.mockReset() }) - it('listTargets delegates to store', () => { - sshStore.listTargets() - expect(mockStore.getSshTargets).toHaveBeenCalled() - }) - it('lists picker suppression aliases without consulting re-adoption tombstones', () => { mockStore.addDeletedSshConfigAlias('config-removed') mockStore.addRemovedSshTargetTombstone({ @@ -166,24 +161,42 @@ describe('SshConnectionStore', () => { expect(sshStore.listTargets()).toEqual([userTarget]) }) - it('updateTarget delegates to store', () => { - const original: SshTarget = { - id: 'ssh-1', - label: 'Old Name', - host: 'example.com', - port: 22, - username: 'user' + it('clears the runtime ladder decision when the runtime choice or endpoint changes', () => { + const resolution = { + rung: 'legacy' as const, + pinnedRefusal: 'missing_lib', + glibc: '2.31', + runtimeSha256: 'a'.repeat(64), + orcaMajor: 1 } - mockStore.addSshTarget(original) + mockStore.addSshTarget({ + id: 'ssh-ladder', + label: 'Ladder', + host: 'ladder.example.com', + port: 22, + username: 'user', + remoteRuntime: 'pinned-node', + remoteRuntimeResolution: resolution + }) - const result = sshStore.updateTarget('ssh-1', { label: 'New Name' }) - expect(result).toBeTruthy() - expect(mockStore.updateSshTarget).toHaveBeenCalledWith('ssh-1', { label: 'New Name' }) - }) + sshStore.updateTarget('ssh-ladder', { label: 'Renamed', remoteRuntime: 'pinned-node' }) + expect(mockStore.updateSshTarget).toHaveBeenLastCalledWith('ssh-ladder', { + label: 'Renamed', + remoteRuntime: 'pinned-node' + }) - it('removeTarget delegates to store', () => { - sshStore.removeTarget('ssh-1') - expect(mockStore.removeSshTarget).toHaveBeenCalledWith('ssh-1') + sshStore.updateTarget('ssh-ladder', { remoteRuntime: 'legacy' }) + expect(mockStore.updateSshTarget).toHaveBeenLastCalledWith('ssh-ladder', { + remoteRuntime: 'legacy', + remoteRuntimeResolution: undefined + }) + + sshStore.updateTarget('ssh-ladder', { remoteRuntimeResolution: resolution }) + sshStore.updateTarget('ssh-ladder', { host: 'other.example.com' }) + expect(mockStore.updateSshTarget).toHaveBeenLastCalledWith('ssh-ladder', { + host: 'other.example.com', + remoteRuntimeResolution: undefined + }) }) describe('importFromSshConfig', () => { diff --git a/src/main/ssh/ssh-connection-store.ts b/src/main/ssh/ssh-connection-store.ts index 08f78c77dd7f..0d67587b920a 100644 --- a/src/main/ssh/ssh-connection-store.ts +++ b/src/main/ssh/ssh-connection-store.ts @@ -85,7 +85,16 @@ export class SshConnectionStore { } updateTarget(id: string, updates: Partial>): SshTarget | null { - const updated = this.store.updateSshTarget(id, updates) + const existing = this.store.getSshTarget(id) + // Why: a new runtime choice or endpoint must re-run the ladder, not replay the old rung. + const changed = (key: 'remoteRuntime' | 'host' | 'port' | 'configHost'): boolean => + key in updates && existing?.[key] !== updates[key] + const resetsRuntime = + changed('remoteRuntime') || changed('host') || changed('port') || changed('configHost') + const updated = this.store.updateSshTarget( + id, + resetsRuntime ? { ...updates, remoteRuntimeResolution: undefined } : updates + ) if (updated) { // Why: actively editing a target reclaims its alias from the deleted set, // so an edit can never leave the host tombstoned. diff --git a/src/main/ssh/ssh-exec-stdin-file-transfer.test.ts b/src/main/ssh/ssh-exec-stdin-file-transfer.test.ts new file mode 100644 index 000000000000..4b481f003285 --- /dev/null +++ b/src/main/ssh/ssh-exec-stdin-file-transfer.test.ts @@ -0,0 +1,157 @@ +import { createHash, randomBytes } from 'node:crypto' +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { chmod } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Duplex } from 'node:stream' +import { afterEach, describe, expect, it } from 'vitest' +import { spawnProcess } from '../../shared/child-process/run-process' +import type { SshConnection } from './ssh-connection' +import { + EXEC_STDIN_WRITE_DONE, + makeExecStdinWriteFileCommand, + uploadDirectoryViaExecStdin, + uploadFileViaExecStdin, + writeStringViaExecStdin +} from './ssh-exec-stdin-file-transfer' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { makePosixWriteFileCommand } from './system-ssh-file-binary-transfer' + +const directories: string[] = [] +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'exec-stdin-')) + directories.push(dir) + return dir +} + +/** An ssh2-shaped exec channel over a local `/bin/sh -c`, so the real writer and shell run. */ +function localShellConnection(rewrite: (command: string) => string = (c) => c): SshConnection { + const conn = { + exec: async (command: string) => { + const child = spawnProcess({ program: '/bin/sh', args: ['-c', rewrite(command)] }) + const channel = new Duplex({ + autoDestroy: false, + emitClose: false, + read() {}, + write(chunk, encoding, callback) { + child.stdin.write(chunk, encoding, callback) + }, + final(callback) { + child.stdin.end(callback) + } + }) + // EPIPE when the shell stops reading early is the command's answer, not a test failure. + child.stdin.on('error', () => {}) + child.stdout.on('data', (data: Buffer) => channel.push(data)) + child.on('close', (code) => { + channel.push(null) + channel.emit('close', code) + }) + return Object.assign(channel, { + stdin: channel, + stderr: child.stderr, + close: () => child.kill('SIGKILL') + }) + } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: execCommand only uses exec() and the channel surface built above. + return conn as unknown as SshConnection +} + +describe('exec-stdin write command construction', () => { + it('keeps the system-SSH writer a plain cat redirection', () => { + expect(makePosixWriteFileCommand('/h/a b')).toBe("cat > '/h/a b'") + expect(makePosixWriteFileCommand('/h/x', { exclusive: true })).toBe("set -C; cat > '/h/x'") + }) + + it('wraps the same writer with a size check and an atomic rename for the ssh2 fallback', () => { + const command = makeExecStdinWriteFileCommand("/h/o'dd/file", 42, { token: 'abc' }) + expect(command).toBe( + [ + "{ mkdir -p '/h/o'\\''dd'", + "cat > '/h/o'\\''dd/file.orca-part-abc'", + `[ "$(wc -c < '/h/o'\\''dd/file.orca-part-abc' | tr -d ' ')" = 42 ]`, + "mv -f '/h/o'\\''dd/file.orca-part-abc' '/h/o'\\''dd/file'", + `echo ${EXEC_STDIN_WRITE_DONE}; } || { rm -f '/h/o'\\''dd/file.orca-part-abc'; exit 1; }` + ].join(' && ') + ) + }) + + it('keeps the executable bit when asked and rejects an impossible size', () => { + expect(makeExecStdinWriteFileCommand('/h/f', 1, { token: 't', executable: true })).toContain( + "chmod 755 '/h/f.orca-part-t'" + ) + expect(() => makeExecStdinWriteFileCommand('/h/f', -1, { token: 't' })).toThrow() + expect(() => makeExecStdinWriteFileCommand('/h/f', 1.5, { token: 't' })).toThrow() + }) +}) + +describe.skipIf(process.platform === 'win32')('exec-stdin writer through a real shell', () => { + it('streams a payload over 10 MB byte-for-byte', async () => { + const dir = tempDir() + const payload = randomBytes(12 * 1024 * 1024 + 7) + const local = join(dir, 'runtime.tar.gz') + writeFileSync(local, payload) + const remote = join(dir, 'remote', 'nested', 'runtime.tar.gz') + + await uploadFileViaExecStdin(localShellConnection(), local, remote) + + const sha = (bytes: Buffer): string => createHash('sha256').update(bytes).digest('hex') + expect(sha(readFileSync(remote))).toBe(sha(payload)) + expect(readdirSync(join(dir, 'remote', 'nested'))).toEqual(['runtime.tar.gz']) + }, 60_000) + + it('leaves no file behind when the host receives fewer bytes than were sent', async () => { + const dir = tempDir() + const local = join(dir, 'payload.bin') + writeFileSync(local, randomBytes(64 * 1024)) + const remote = join(dir, 'remote', 'payload.bin') + // Models a stream cut short: the host keeps only the first 1000 bytes. + const truncating = localShellConnection((command) => + command.replace(/cat > /, 'head -c 1000 > ') + ) + + await expect(uploadFileViaExecStdin(truncating, local, remote)).rejects.toThrow(/exit 1/) + expect(existsSync(remote)).toBe(false) + expect(readdirSync(join(dir, 'remote'))).toEqual([]) + }) + + it('uploads a directory tree, including empty directories and executable modes', async () => { + const dir = tempDir() + const local = join(dir, 'local') + mkdirSync(join(local, 'bin'), { recursive: true }) + mkdirSync(join(local, 'empty')) + writeFileSync(join(local, 'relay.js'), 'console.log(1)\n') + writeFileSync(join(local, 'bin', 'spawn-helper'), '#!/bin/sh\n') + await chmod(join(local, 'bin', 'spawn-helper'), 0o755) + const remote = join(dir, 'remote') + + await uploadDirectoryViaExecStdin( + localShellConnection(), + local, + remote, + getRemoteHostPlatform('linux-x64') + ) + await writeStringViaExecStdin(localShellConnection(), join(remote, '.version'), '0.1.0+abc') + + expect(readFileSync(join(remote, 'relay.js'), 'utf8')).toBe('console.log(1)\n') + expect(existsSync(join(remote, 'empty'))).toBe(true) + expect(readFileSync(join(remote, '.version'), 'utf8')).toBe('0.1.0+abc') + expect(statSync(join(remote, 'bin', 'spawn-helper')).mode & 0o111).not.toBe(0) + }) +}) diff --git a/src/main/ssh/ssh-exec-stdin-file-transfer.ts b/src/main/ssh/ssh-exec-stdin-file-transfer.ts new file mode 100644 index 000000000000..fc105ac31933 --- /dev/null +++ b/src/main/ssh/ssh-exec-stdin-file-transfer.ts @@ -0,0 +1,131 @@ +/** + * Upload by streaming bytes into a POSIX exec channel's stdin (design D5 transfer fallback), for + * hosts whose SFTP subsystem is disabled or chrooted (#12868, #15479). Works on either transport + * because it only needs `conn.exec`; the system-SSH transport already writes this way. + * + * Each file lands under a unique partial name and is renamed only after the host counts exactly + * the bytes that were sent, so a transfer cut short never leaves a plausible-looking file behind. + */ +import { randomBytes } from 'node:crypto' +import { constants } from 'node:fs' +import { lstat, open } from 'node:fs/promises' +import { posix } from 'node:path' +import { Readable } from 'node:stream' +import type { SshConnection } from './ssh-connection' +import { shellEscape } from './ssh-connection-utils' +import { execCommand } from './ssh-relay-exec-command' +import { RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing' +import type { RemoteHostPlatform } from './ssh-remote-platform' +import { makePosixWriteFileCommand } from './system-ssh-file-binary-transfer' +import { collectLocalUploadPlan } from './system-ssh-file-transfer' + +export const EXEC_STDIN_WRITE_DONE = 'ORCA_EXEC_STDIN_WRITE_DONE' + +type ExecStdinOptions = { signal?: AbortSignal } + +/** `cat` into a partial file, check its size, then rename it into place. */ +export function makeExecStdinWriteFileCommand( + remotePath: string, + byteLength: number, + options: { token: string; executable?: boolean } +): string { + if (!Number.isSafeInteger(byteLength) || byteLength < 0) { + throw new Error(`Invalid upload size: ${byteLength}`) + } + const part = `${remotePath}.orca-part-${options.token}` + const escapedPart = shellEscape(part) + const steps = [ + `mkdir -p ${shellEscape(posix.dirname(remotePath))}`, + makePosixWriteFileCommand(part), + // Why tr: BSD `wc -c` pads its count with spaces. + `[ "$(wc -c < ${escapedPart} | tr -d ' ')" = ${byteLength} ]`, + ...(options.executable ? [`chmod 755 ${escapedPart}`] : []), + `mv -f ${escapedPart} ${shellEscape(remotePath)}`, + `echo ${EXEC_STDIN_WRITE_DONE}` + ] + return `{ ${steps.join(' && ')}; } || { rm -f ${escapedPart}; exit 1; }` +} + +async function writeViaExecStdin( + conn: SshConnection, + remotePath: string, + byteLength: number, + stdin: Readable, + executable: boolean, + options?: ExecStdinOptions +): Promise { + const token = randomBytes(8).toString('hex') + const output = await execCommand( + conn, + makeExecStdinWriteFileCommand(remotePath, byteLength, { token, executable }), + // Why the deploy bound: a runtime archive over a slow link outlasts the 30 s probe default. + { signal: options?.signal, stdin, timeoutMs: RELAY_DEPLOY_TIMEOUT_MS } + ) + if (output.trim().split('\n').at(-1) !== EXEC_STDIN_WRITE_DONE) { + throw new Error(`The host did not confirm the upload of ${remotePath}`) + } +} + +export async function uploadFileViaExecStdin( + conn: SshConnection, + localPath: string, + remotePath: string, + options?: ExecStdinOptions +): Promise { + options?.signal?.throwIfAborted() + const sourceStat = await lstat(localPath) + if (sourceStat.isSymbolicLink() || !sourceStat.isFile()) { + throw new Error(`Unsupported upload source: ${localPath}`) + } + const handle = await open(localPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) + try { + const opened = await handle.stat() + // Same identity check as the system-SSH writer; some Windows filesystems report inode 0. + if ( + !opened.isFile() || + opened.size !== sourceStat.size || + (sourceStat.ino !== 0 && opened.ino !== 0 && opened.ino !== sourceStat.ino) + ) { + throw new Error(`File changed during upload: ${localPath}`) + } + const input = handle.createReadStream({ autoClose: false }) + await writeViaExecStdin( + conn, + remotePath, + opened.size, + input, + (opened.mode & 0o111) !== 0, + options + ) + } finally { + await handle.close() + } +} + +export async function writeStringViaExecStdin( + conn: SshConnection, + remotePath: string, + contents: string, + options?: ExecStdinOptions +): Promise { + const bytes = Buffer.from(contents, 'utf-8') + await writeViaExecStdin(conn, remotePath, bytes.length, Readable.from([bytes]), false, options) +} + +/** One exec creates the tree (empty directories included), then one exec per file. */ +export async function uploadDirectoryViaExecStdin( + conn: SshConnection, + localDir: string, + remoteDir: string, + host: RemoteHostPlatform, + options?: ExecStdinOptions +): Promise { + const plan = await collectLocalUploadPlan(localDir, remoteDir, host, options?.signal) + await execCommand(conn, `mkdir -p ${plan.directories.map(shellEscape).join(' ')}`, { + signal: options?.signal + }) + for (const file of plan.files) { + options?.signal?.throwIfAborted() + await uploadFileViaExecStdin(conn, file.localPath, file.remotePath, options) + } +} diff --git a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts index be66463206a7..f519dbab4fbd 100644 --- a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts +++ b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts @@ -61,6 +61,7 @@ vi.mock('./ssh-connection-utils', () => ({ import { deployAndLaunchRelay } from './ssh-relay-deploy' import { execCommand } from './ssh-relay-deploy-helpers' import type { SshConnection } from './ssh-connection' +import { REMOTE_INSTALL_ORDER_OK } from './remote-install-previous-version' function makeMockConnection(): SshConnection { return { @@ -150,6 +151,12 @@ describe('cross-version isolation', () => { if (command.includes('test -S') && command.includes('echo ALIVE || echo DEAD')) { return Promise.resolve('DEAD') } + if (command.includes(REMOTE_INSTALL_ORDER_OK)) { + // A newer v0 is the previous build, so v1 is kept only by its live socket. + return Promise.resolve( + `relay-0.1.0+222222222222\nrelay-0.1.0+000000000000\nrelay-0.1.0+111111111111\n${REMOTE_INSTALL_ORDER_OK}` + ) + } if (command.includes('__ORCA_RELAY_GC_FIND_STATUS__')) { return Promise.resolve('relay-0.1.0+111111111111\nrelay-0.1.0+222222222222\n') } diff --git a/src/main/ssh/ssh-relay-deploy-pinned-node.test.ts b/src/main/ssh/ssh-relay-deploy-pinned-node.test.ts new file mode 100644 index 000000000000..1d722c36a9dd --- /dev/null +++ b/src/main/ssh/ssh-relay-deploy-pinned-node.test.ts @@ -0,0 +1,528 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RelayRipgrepInstallModule from './ssh-relay-ripgrep-install' + +vi.mock('electron', () => ({ + app: { getAppPath: () => '/mock/app' } +})) + +// Why: deployAndLaunchRelay now reads `${localRelayDir}/.version` upfront +// (per docs/ssh-relay-versioned-install-dirs.md). The fs mock must report +// the local relay package as existing AND return a content-hashed version +// string so readLocalFullVersion succeeds. +vi.mock('fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue('0.1.0+abcdef012345') +})) + +vi.mock('./relay-protocol', () => ({ + RELAY_VERSION: '0.1.0', + RELAY_REMOTE_DIR: '.orca-remote', + parseUnameToRelayPlatform: vi.fn((os: string, arch: string) => { + const normalizedOs = os.toLowerCase() + const normalizedArch = arch.toLowerCase() + const relayArch = normalizedArch === 'arm64' || normalizedArch === 'aarch64' ? 'arm64' : 'x64' + if (normalizedOs === 'windows' || normalizedOs === 'win32') { + return `win32-${relayArch}` + } + if (normalizedOs === 'darwin') { + return `darwin-${relayArch}` + } + if (normalizedOs === 'linux') { + return `linux-${relayArch}` + } + return null + }), + RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', + RELAY_SENTINEL_TIMEOUT_MS: 10_000 +})) + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + uploadDirectory: vi.fn().mockResolvedValue(undefined), + waitForSentinel: vi.fn().mockResolvedValue({ + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }), + isUnconfirmedSshCommandTermination: (error: unknown) => + error instanceof Error && + 'sshChannelCloseConfirmed' in error && + error.sshChannelCloseConfirmed === false, + execCommand: vi.fn().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') +})) + +vi.mock('./ssh-remote-node-resolution', () => ({ + resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') +})) + +// Why: this file mocks fs, so the real content hash cannot read a binary. +vi.mock('../ripgrep/bundled-ripgrep-path', () => ({ + resolveBundledRipgrepPath: () => null, + bundledRipgrepContentKey: () => 'c0ffee0123456789' +})) + +// Why: the fire-and-forget ripgrep install would drain the queued exec mocks. +// Why: the post-launch ripgrep cache GC is fire-and-forget and would drain the queued exec mocks. +vi.mock('./ssh-relay-ripgrep-cache-gc', () => ({ gcRemoteRipgrepCache: vi.fn() })) +vi.mock('./ssh-relay-opencode-runtime', () => ({ + ensureRemoteOpenCodeRuntime: vi.fn().mockResolvedValue('ready') +})) +vi.mock('./ssh-relay-ripgrep-install', async (importOriginal) => ({ + ...(await importOriginal()), + ensureRemoteBundledRipgrep: vi.fn().mockResolvedValue('present'), + recordRemoteRipgrepReference: vi.fn().mockResolvedValue(true) +})) + +// Why: the versioned-install modules shell out for install state, locking, +// and GC. Stub them so deploy tests need no real SSH connection. +vi.mock('./ssh-relay-versioned-install', () => ({ + readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+abcdef012345'), + computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, + isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), + finalizeInstall: vi.fn().mockResolvedValue(undefined), + abandonInstall: vi.fn().mockResolvedValue(undefined), + gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-relay-install-lock', () => ({ + acquireInstallLock: vi.fn().mockResolvedValue(undefined), + RELAY_INSTALL_LOCK_NAME: '.install-lock' +})) + +vi.mock('./ssh-relay-repair-lock', () => ({ + tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') +})) + +vi.mock('./ssh-connection-utils', () => ({ + shellEscape: (s: string) => `'${s}'`, + createSshOperationAbortError: () => + Object.assign(new Error('SSH operation was cancelled'), { + name: 'AbortError' + }) +})) + +vi.mock('./ssh-relay-pinned-node', async (importOriginal) => ({ + ...(await importOriginal>()), + planPinnedNodeRelay: vi.fn(), + resolvePinnedRelayTargetFacts: vi.fn() +})) + +vi.mock('./ssh-relay-pinned-node-install', async (importOriginal) => ({ + ...(await importOriginal>()), + ensurePinnedRelayRuntime: vi.fn().mockResolvedValue(undefined), + verifyPinnedRelayInstall: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-relay-host-node-addons', async (importOriginal) => ({ + ...(await importOriginal>()), + planHostNodeAddonRelay: vi.fn() +})) + +vi.mock('./ssh-target-registry', () => ({ getSshTargetRegistryStore: vi.fn(() => null) })) +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) + +vi.mock('./remote-node-runtime-store-gc', () => ({ + gcRemoteNodeRuntimeStore: vi.fn().mockResolvedValue({ state: 'skipped', reason: 'test' }) +})) + +import { deployAndLaunchRelay } from './ssh-relay-deploy' +import { gcRemoteNodeRuntimeStore } from './remote-node-runtime-store-gc' +import { execCommand } from './ssh-relay-deploy-helpers' +import { resolveRemoteNodePath } from './ssh-remote-node-resolution' +import { + finalizeInstall, + gcOldRelayVersions, + isRelayAlreadyInstalled +} from './ssh-relay-versioned-install' +import { + PinnedRelayFallbackError, + planPinnedNodeRelay, + resolvePinnedRelayTargetFacts, + type PinnedRelayPlan +} from './ssh-relay-pinned-node' +import { ensurePinnedRelayRuntime, verifyPinnedRelayInstall } from './ssh-relay-pinned-node-install' +import { planHostNodeAddonRelay, type HostNodeAddonRelayPlan } from './ssh-relay-host-node-addons' +import { RemoteRuntimeUnavailableError } from './ssh-relay-runtime-resolution' +import { resetSshRemoteRuntimeTelemetryForTests } from './ssh-remote-runtime-telemetry' +import { getSshTargetRegistryStore } from './ssh-target-registry' +import { track } from '../telemetry/client' +import type { SshConnection } from './ssh-connection' +import type { SshConnectionStore } from './ssh-connection-store' +import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin' +import type { SshRemoteRuntime, SshTarget } from '../../shared/ssh-types' +import { terminalUnavailableCauseFromError } from '../../shared/terminal-unavailable-cause' +import { decodeRemotePowerShellScript } from './ssh-remote-powershell' + +const PINNED_VERSION = '0.1.0+feedfacecafe' +const RUNTIME_SHA = NODE_RUNTIME_ASSETS['linux-x64-glibc'].executableSha256 +const PINNED_NODE = `/home/user/.orca-remote/runtimes/node-${RUNTIME_SHA}/bin/node` + +function pinnedPlan(): PinnedRelayPlan { + return { + kind: 'pinned-node', + target: 'linux-x64-glibc', + glibc: { major: 2, minor: 31 }, + fullVersion: PINNED_VERSION, + addons: { dir: '/tmp/addons', digest: 'd', dispose: vi.fn().mockResolvedValue(undefined) }, + runtimeArchive: vi.fn() + } +} + +const HOST_NODE_VERSION = '0.1.0+0123456789ab' + +function hostNodePlan(): HostNodeAddonRelayPlan { + return { + kind: 'host-node-addons', + target: 'linux-x64-glibc', + glibc: { major: 2, minor: 31 }, + fullVersion: HOST_NODE_VERSION, + addons: { dir: '/tmp/host-addons', digest: 'd', dispose: vi.fn().mockResolvedValue(undefined) }, + nodePath: '/opt/node18/bin/node', + hostNode: { version: { major: 18, minor: 20 }, napi: 9 } + } +} + +function makeConnection(remoteRuntime?: SshRemoteRuntime): SshConnection { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the deploy path under test touches only these members of the connection. + return { + canRunConcurrentExecCommands: vi.fn().mockReturnValue(true), + getTarget: () => ({ id: 'target-1', ...(remoteRuntime ? { remoteRuntime } : {}) }), + exec: vi.fn().mockResolvedValue({ + on: vi.fn(), + stderr: { on: vi.fn() }, + stdin: {}, + stdout: { on: vi.fn() }, + close: vi.fn() + }) + } as unknown as SshConnection +} + +function detachedLaunchCommand(conn: SshConnection): string | undefined { + return vi + .mocked(conn.exec) + .mock.calls.map(([cmd]) => String(cmd)) + .find((cmd) => cmd.includes('--detached')) +} + +function queueInstalledPinnedLaunch(): void { + vi.mocked(execCommand) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/user') + .mockResolvedValueOnce('') // launch namespace marker + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('READY') +} + +function queueInstalledLegacyLaunch(options: { platformProbed?: boolean } = {}): void { + // A later ladder step reuses the platform the first step detected. + if (!options.platformProbed) { + vi.mocked(execCommand).mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + } + vi.mocked(execCommand) + .mockResolvedValueOnce('/home/user') + .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') + .mockResolvedValueOnce('') // launch namespace marker + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('READY') +} + +describe('deployAndLaunchRelay on the pinned Node runtime', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(execCommand).mockReset().mockResolvedValue('') + vi.mocked(resolveRemoteNodePath).mockReset().mockResolvedValue('/usr/bin/node') + vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(true) + vi.mocked(planPinnedNodeRelay).mockReset().mockResolvedValue(pinnedPlan()) + vi.mocked(ensurePinnedRelayRuntime).mockReset().mockResolvedValue(undefined) + vi.mocked(resolvePinnedRelayTargetFacts) + .mockReset() + .mockResolvedValue({ target: 'linux-x64-glibc', glibc: { major: 2, minor: 31 } }) + vi.mocked(planHostNodeAddonRelay) + .mockReset() + .mockRejectedValue(new PinnedRelayFallbackError('libc_floor', 'addons refused')) + vi.mocked(getSshTargetRegistryStore).mockReset().mockReturnValue(null) + resetSshRemoteRuntimeTelemetryForTests() + }) + + it('keeps the legacy host-Node path when the host has no runtime setting', async () => { + const conn = makeConnection() + queueInstalledLegacyLaunch() + + const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') + + expect(planPinnedNodeRelay).not.toHaveBeenCalled() + expect(result.nodePath).toBe('/usr/bin/node') + expect(result.serverBuildId).toBe('0.1.0+abcdef012345') + }) + + it('launches from the runtime-folded version dir with the pinned Node and no host Node probe', async () => { + const conn = makeConnection('pinned-node') + queueInstalledPinnedLaunch() + + const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') + + expect(resolveRemoteNodePath).not.toHaveBeenCalled() + expect(result.serverBuildId).toBe(PINNED_VERSION) + expect(result.remoteRelayDir).toBe(`/home/user/.orca-remote/relay-${PINNED_VERSION}`) + expect(result.nodePath).toBe(PINNED_NODE) + expect(detachedLaunchCommand(conn)).toContain(`'${PINNED_NODE}' relay.js --detached`) + expect( + vi.mocked(execCommand).mock.calls.some(([, cmd]) => String(cmd).includes('NATIVE-DEPS')) + ).toBe(false) + }) + + it('collects the runtime store after a launch, keeping the pin it runs', async () => { + const conn = makeConnection('pinned-node') + queueInstalledPinnedLaunch() + + await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') + + await vi.waitFor(() => expect(gcRemoteNodeRuntimeStore).toHaveBeenCalledOnce()) + expect(gcRemoteNodeRuntimeStore).toHaveBeenCalledWith( + conn, + expect.objectContaining({ os: 'linux' }), + '/home/user', + { currentPins: [RUNTIME_SHA] } + ) + // Why after: the version pass is what drops the refs that held superseded runtimes. + expect(vi.mocked(gcOldRelayVersions).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(gcRemoteNodeRuntimeStore).mock.invocationCallOrder[0] + ) + }) + + it('leaves the runtime store alone on the legacy host-Node path', async () => { + const conn = makeConnection() + queueInstalledLegacyLaunch() + + await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') + + await vi.waitFor(() => expect(gcOldRelayVersions).toHaveBeenCalled()) + await new Promise((resolve) => setImmediate(resolve)) + expect(gcRemoteNodeRuntimeStore).not.toHaveBeenCalled() + }) + + it('steps down the ladder to the host-npm relay on classified refusals', async () => { + const conn = makeConnection('pinned-node') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.mocked(ensurePinnedRelayRuntime).mockRejectedValueOnce( + new PinnedRelayFallbackError('missing_lib', 'libstdc++.so.6: cannot open') + ) + vi.mocked(execCommand) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/user') + queueInstalledLegacyLaunch({ platformProbed: true }) + + const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') + + expect(result.nodePath).toBe('/usr/bin/node') + expect(result.serverBuildId).toBe('0.1.0+abcdef012345') + expect(detachedLaunchCommand(conn)).toContain("'/usr/bin/node' relay.js --detached") + expect(warn).toHaveBeenCalledWith(expect.stringContaining('rung A unavailable (missing_lib)')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('rung B unavailable')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('rung C unavailable (libc_floor)')) + warn.mockRestore() + }) + + it('does not step down when the runtime check is unverifiable', async () => { + const conn = makeConnection('pinned-node') + const unverifiable = new Error('The pinned Node self-test is unverifiable') + vi.mocked(ensurePinnedRelayRuntime).mockRejectedValueOnce(unverifiable) + vi.mocked(execCommand) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/user') + + await expect(deployAndLaunchRelay(conn, undefined, undefined, 'target-1')).rejects.toBe( + unverifiable + ) + expect(resolveRemoteNodePath).not.toHaveBeenCalled() + expect(detachedLaunchCommand(conn)).toBeUndefined() + }) + + it('releases the staged addons after the attempt', async () => { + const conn = makeConnection('pinned-node') + const plan = pinnedPlan() + vi.mocked(planPinnedNodeRelay).mockResolvedValueOnce(plan) + queueInstalledPinnedLaunch() + + await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') + + expect(plan.addons.dispose).toHaveBeenCalledOnce() + }) + + it('uploads the relay and its addons, then self-tests instead of installing native deps', async () => { + const conn = makeConnection('pinned-node') + const uploadDirectory = vi.fn().mockResolvedValue(undefined) + Object.assign(conn, { uploadDirectory, writeFile: vi.fn().mockResolvedValue(undefined) }) + vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(false) + vi.mocked(execCommand).mockImplementation((_conn, command) => { + const marker = command.match(/\.sftp-namespace-[0-9a-f]{32}/u)?.[0] + if (command.includes('uname')) { + return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + } + if (command === 'echo $HOME') { + return Promise.resolve('/home/user') + } + if (command.includes('__ORCA_UPLOAD_STAGE_SLOT__') && marker) { + return Promise.resolve(`__ORCA_UPLOAD_STAGE_SLOT__${marker}:slot-0`) + } + if (command.includes('__ORCA_UPLOAD_STAGE_PROMOTION__') && marker) { + return Promise.resolve(`__ORCA_UPLOAD_STAGE_PROMOTION__${marker}:PROMOTED`) + } + if (command.includes('process.stdout.write("READY")')) { + return Promise.resolve('READY') + } + return Promise.resolve(command.includes('test -S') ? 'DEAD' : '') + }) + + await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') + + expect(uploadDirectory.mock.calls.map(([local]) => local)).toContain('/tmp/addons') + expect(ensurePinnedRelayRuntime).toHaveBeenCalledWith( + expect.objectContaining({ + remoteRelayDir: `/home/user/.orca-remote/relay-${PINNED_VERSION}` + }), + false + ) + expect(verifyPinnedRelayInstall).toHaveBeenCalledOnce() + expect(vi.mocked(verifyPinnedRelayInstall).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(finalizeInstall).mock.invocationCallOrder[0] + ) + expect( + vi.mocked(execCommand).mock.calls.some(([, cmd]) => /npm (?:install|ci)/.test(String(cmd))) + ).toBe(false) + expect(detachedLaunchCommand(conn)).toContain(`'${PINNED_NODE}' relay.js --detached`) + }) + + it('runs rung C on the host Node with the prebuilt addons and no npm', async () => { + const conn = makeConnection('pinned-node') + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.mocked(planPinnedNodeRelay).mockResolvedValueOnce({ + kind: 'host-node', + fallbackReason: 'artifacts_unavailable' + }) + const plan = hostNodePlan() + vi.mocked(planHostNodeAddonRelay).mockReset().mockResolvedValueOnce(plan) + queueInstalledPinnedLaunch() + + const result = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1') + + expect(resolveRemoteNodePath).not.toHaveBeenCalled() + expect(ensurePinnedRelayRuntime).not.toHaveBeenCalled() + expect(result.serverBuildId).toBe(HOST_NODE_VERSION) + expect(result.nodePath).toBe('/opt/node18/bin/node') + expect(detachedLaunchCommand(conn)).toContain("'/opt/node18/bin/node' relay.js --detached") + expect(plan.addons.dispose).toHaveBeenCalledOnce() + expect(track).toHaveBeenCalledWith( + 'ssh_remote_runtime_resolved', + expect.objectContaining({ + rung: 'c', + first_refusal: 'artifacts_unavailable', + host_node_major: '18', + host_libc: 'glibc', + glibc_minor: '31' + }) + ) + }) + + it('goes straight to rung D on noexec, with the classified reason and no launch', async () => { + const conn = makeConnection('pinned-node') + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.mocked(ensurePinnedRelayRuntime).mockRejectedValueOnce( + new PinnedRelayFallbackError('noexec', 'exit 126: Permission denied') + ) + vi.mocked(execCommand) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/user') + + const failure = await deployAndLaunchRelay(conn, undefined, undefined, 'target-1').catch( + (error: unknown) => error + ) + + expect(failure).toBeInstanceOf(RemoteRuntimeUnavailableError) + expect(String(failure)).toContain('mounted noexec') + expect(terminalUnavailableCauseFromError(failure)).toMatchObject({ + status: 'blocked', + reason: 'home_noexec', + repairable: false + }) + expect(planHostNodeAddonRelay).not.toHaveBeenCalled() + expect(resolveRemoteNodePath).not.toHaveBeenCalled() + expect(detachedLaunchCommand(conn)).toBeUndefined() + expect(track).toHaveBeenCalledWith( + 'ssh_remote_runtime_resolved', + expect.objectContaining({ rung: 'd', first_refusal: 'noexec' }) + ) + }) + + it('persists the rung A refusal under the host key and skips A on the next connect', async () => { + const stored: Partial = {} + const registry = { + getTarget: vi.fn(() => ({ id: 'target-1', ...stored })), + updateTarget: vi.fn((_id: string, updates: Partial) => { + Object.assign(stored, updates) + return null + }) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ladder reads and writes only these two registry members. + vi.mocked(getSshTargetRegistryStore).mockReturnValue(registry as unknown as SshConnectionStore) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.mocked(ensurePinnedRelayRuntime).mockRejectedValueOnce( + new PinnedRelayFallbackError('illegal_instruction', 'SIGILL') + ) + vi.mocked(execCommand) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/user') + queueInstalledLegacyLaunch({ platformProbed: true }) + + await deployAndLaunchRelay(makeConnection('pinned-node'), undefined, undefined, 'target-1') + + expect(stored.remoteRuntimeResolution).toMatchObject({ + rung: 'legacy', + pinnedRefusal: 'illegal_instruction', + glibc: '2.31', + runtimeSha256: RUNTIME_SHA + }) + const persisted = vi.mocked(planPinnedNodeRelay).mock.calls[0]![0].persistedRefusal + expect(persisted?.({ target: 'linux-x64-glibc', glibc: { major: 2, minor: 31 } })).toBe( + 'illegal_instruction' + ) + // A different glibc is a different key: the cached refusal no longer applies. + expect(persisted?.({ target: 'linux-x64-glibc', glibc: { major: 2, minor: 35 } })).toBeNull() + expect(track).toHaveBeenCalledOnce() + }) + + it('launches a Windows relay on node.exe from the runtimes store, with no host Node probe', async () => { + const conn = makeConnection('pinned-node') + Object.assign(conn, { writeFile: vi.fn().mockResolvedValue(undefined) }) + const sha = NODE_RUNTIME_ASSETS['win32-x64'].executableSha256 + const nodeExe = `C:/Users/me/.orca-remote/runtimes/node-${sha}/node.exe` + vi.mocked(planPinnedNodeRelay).mockResolvedValueOnce({ + ...pinnedPlan(), + target: 'win32-x64', + glibc: null + }) + vi.mocked(execCommand) + .mockRejectedValueOnce(new Error('uname not found')) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') + .mockResolvedValueOnce('C:\\Users\\me') + .mockResolvedValueOnce('') // no persisted active pipe + .mockResolvedValueOnce('WAITING') // named pipe probe + .mockResolvedValueOnce('') // WMI relay launch + .mockResolvedValueOnce('READY') // named pipe poll + .mockResolvedValueOnce('') // persist active pipe marker + + const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-1') + + expect(resolveRemoteNodePath).not.toHaveBeenCalled() + expect(result.nodePath).toBe(nodeExe) + expect(result.remoteRelayDir).toBe(`C:/Users/me/.orca-remote/relay-${PINNED_VERSION}`) + expect(ensurePinnedRelayRuntime).toHaveBeenCalledWith( + expect.objectContaining({ host: expect.objectContaining({ os: 'win32' }) }), + true + ) + const launchScript = vi + .mocked(execCommand) + .mock.calls.map(([, command]) => decodeRemotePowerShellScript(String(command))) + .find((script) => script.includes('Invoke-CimMethod')) + expect(launchScript).toContain(nodeExe) + }) +}) diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index 4424adc0c011..e416ab9e2f96 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -2,6 +2,27 @@ import { existsSync } from 'node:fs' import { app } from 'electron' import { relayBundleCandidates } from './relay-bundle-paths' +import { PinnedRelayFallbackError, resolveSshRemoteRuntime } from './ssh-relay-pinned-node' +import { + ensurePinnedRelayRuntime, + prebuiltRelayNodePath, + verifyPinnedRelayInstall +} from './ssh-relay-pinned-node-install' +import type { PrebuiltRelayPlan } from './ssh-relay-host-node-addons' +import { + nextRelayRuntimeStep, + relayRuntimeLadder, + type RelayRuntimeStep +} from './ssh-relay-runtime-ladder' +import { + RelayRuntimeLadderRun, + remoteRuntimeUnavailableError, + sshTargetRelayRuntimeDecisionStore +} from './ssh-relay-runtime-resolution' +import { planRelayRuntimeStep } from './ssh-relay-runtime-step-plan' +import { getSshTargetRegistryStore } from './ssh-target-registry' +import { gcRemoteNodeRuntimeStore } from './remote-node-runtime-store-gc' +import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin' import type { SshConnection } from './ssh-connection' import { RELAY_REMOTE_DIR, type RelayPlatform } from './relay-protocol' import type { MultiplexerTransport } from './ssh-channel-multiplexer' @@ -361,6 +382,25 @@ async function resolveRelayBootstrapState( } } +/** A prebuilt plan already knows its Node, so only home and install state are probed. */ +async function resolvePrebuiltRelayBootstrapState( + conn: SshConnection, + hostPlatform: RemoteHostPlatform, + fullVersion: string, + plan: PrebuiltRelayPlan, + signal?: AbortSignal +): Promise { + const installState = await resolveRemoteInstallState(conn, hostPlatform, fullVersion, { signal }) + return { + ...installState, + nodePath: prebuiltRelayNodePath({ + host: hostPlatform, + remoteRelayDir: installState.remoteRelayDir, + plan + }) + } +} + function isAbortError(err: unknown): boolean { return err instanceof Error && err.name === 'AbortError' } @@ -376,17 +416,44 @@ async function deployAndLaunchRelayInner( relayInstanceId?: string, deploySignal?: AbortSignal ): Promise { + const target = typeof conn.getTarget === 'function' ? conn.getTarget() : undefined + const ladder = relayRuntimeLadder(resolveSshRemoteRuntime(target)) + const registry = getSshTargetRegistryStore() + const run = new RelayRuntimeLadderRun( + target?.id ?? relayInstanceId ?? '', + target && registry ? sshTargetRelayRuntimeDecisionStore(registry) : null + ) + let step = ladder[0] ?? 'legacy' while (true) { deploySignal?.throwIfAborted() try { - return await deployAndLaunchRelayAttempt( + if (step === 'D') { + run.settle('D') + throw remoteRuntimeUnavailableError(run) + } + const result = await deployAndLaunchRelayAttempt( conn, onProgress, graceTimeSeconds, relayInstanceId, - deploySignal + deploySignal, + { step, run } ) + // Why only a laddered pass: a plain host-npm connect has no rung decision to record. + if (ladder.length > 1) { + run.settle(step) + } + return result } catch (err) { + if (err instanceof PinnedRelayFallbackError && step !== 'legacy' && step !== 'D') { + console.warn( + `[ssh-relay] Relay runtime rung ${step} unavailable (${err.reason}): ${err.detail}` + ) + run.refused(step, err.reason) + step = nextRelayRuntimeStep(ladder, step, err.reason, err.remembered) + run.enter(step) + continue + } if (!(err instanceof RelayDirectoryGcConflictError)) { throw err } @@ -396,16 +463,24 @@ async function deployAndLaunchRelayInner( } } +type RelayRuntimeRequest = { step: RelayRuntimeStep; run: RelayRuntimeLadderRun } + async function deployAndLaunchRelayAttempt( conn: SshConnection, onProgress?: (status: string) => void, graceTimeSeconds?: number, relayInstanceId?: string, - deploySignal?: AbortSignal + deploySignal?: AbortSignal, + runtimeRequest: RelayRuntimeRequest = { + step: 'legacy', + run: new RelayRuntimeLadderRun(relayInstanceId ?? '', null) + } ): Promise { onProgress?.('Detecting remote platform...') console.log('[ssh-relay] Detecting remote platform...') - const hostPlatform = await detectRemoteHostPlatform(conn, { signal: deploySignal }) + // Why reuse: each ladder step would otherwise re-probe a platform that cannot have changed. + const hostPlatform = + runtimeRequest.run.host ?? (await detectRemoteHostPlatform(conn, { signal: deploySignal })) if (!hostPlatform) { throw new Error( 'Unsupported remote platform. Orca relay supports: linux-x64, linux-arm64, darwin-x64, darwin-arm64, win32-x64, win32-arm64.' @@ -422,14 +497,88 @@ async function deployAndLaunchRelayAttempt( ) } // Why: content-hashed version doubles as remote dir name and wire-handshake version; throws on missing rather than falling back (see docs/ssh-relay-versioned-install-dirs.md). - const fullVersion = readLocalFullVersion(localRelayDir) + const baseVersion = readLocalFullVersion(localRelayDir) + const prebuilt = await planRelayRuntimeStep({ + conn, + host: hostPlatform, + baseVersion, + step: runtimeRequest.step, + run: runtimeRequest.run, + signal: deploySignal + }) + try { + return await deployAndLaunchRelayOnRuntime({ + conn, + onProgress, + graceTimeSeconds, + relayInstanceId, + deploySignal, + hostPlatform, + platform, + localRelayDir, + fullVersion: prebuilt?.fullVersion ?? baseVersion, + prebuilt, + run: runtimeRequest.run + }) + } finally { + await prebuilt?.addons.dispose().catch(() => {}) + } +} +async function deployAndLaunchRelayOnRuntime({ + conn, + onProgress, + graceTimeSeconds, + relayInstanceId, + deploySignal, + hostPlatform, + platform, + localRelayDir, + fullVersion, + prebuilt, + run +}: { + conn: SshConnection + onProgress?: (status: string) => void + graceTimeSeconds?: number + relayInstanceId?: string + deploySignal?: AbortSignal + hostPlatform: RemoteHostPlatform + platform: RelayPlatform + localRelayDir: string + /** Already folded with the runtime digest when `prebuilt` is set (design D8.1). */ + fullVersion: string + prebuilt?: PrebuiltRelayPlan + run: RelayRuntimeLadderRun +}): Promise { onProgress?.('Checking existing relay...') // Why: install-check and node resolution are independent; run concurrently to save a round trip, with sequential fallback for restrictive SSH servers. - const { remoteHome, remoteRelayDir, alreadyInstalled, nodePath } = - await resolveRelayBootstrapState(conn, hostPlatform, fullVersion, deploySignal) + const { remoteHome, remoteRelayDir, alreadyInstalled, nodePath } = prebuilt + ? await resolvePrebuiltRelayBootstrapState( + conn, + hostPlatform, + fullVersion, + prebuilt, + deploySignal + ) + : await resolveRelayBootstrapState(conn, hostPlatform, fullVersion, deploySignal) console.log(`[ssh-relay] Remote dir: ${remoteRelayDir}`) console.log(`[ssh-relay] Already installed at ${fullVersion}: ${alreadyInstalled}`) + const pinnedContext = prebuilt + ? { + conn, + host: hostPlatform, + remoteRelayDir, + plan: prebuilt, + targetId: run.targetId, + signal: deploySignal, + run + } + : undefined + if (pinnedContext?.plan.kind === 'pinned-node') { + onProgress?.('Checking Orca Node runtime...') + await ensurePinnedRelayRuntime({ ...pinnedContext, plan: pinnedContext.plan }, alreadyInstalled) + } // Why: derive the home-relative suffix once — recomputing it by stripping the shell home breaks on a split namespace. const homeRelativeRelayDir = relayHomeRelativeDir(fullVersion) @@ -445,15 +594,23 @@ async function deployAndLaunchRelayAttempt( let launchGcClaimToken: string | undefined let launchNamespace: RelayInstallNamespace | undefined if (alreadyInstalled) { - const launchFence = await repairInstalledNativeDeps( - conn, - remoteRelayDir, - platform, - hostPlatform, - nodePath, - homeRelativeRelayDir, - deploySignal - ) + const launchFence = prebuilt + ? await fenceInstalledPinnedRelay( + conn, + remoteRelayDir, + hostPlatform, + homeRelativeRelayDir, + deploySignal + ) + : await repairInstalledNativeDeps( + conn, + remoteRelayDir, + platform, + hostPlatform, + nodePath, + homeRelativeRelayDir, + deploySignal + ) ownsInstallLock = launchFence.ownsInstallLock launchGcClaimToken = launchFence.gcClaimToken launchNamespace = launchFence.sftpNamespace @@ -499,7 +656,8 @@ async function deployAndLaunchRelayAttempt( fullVersion, hostPlatform, deploySignal, - { rootDir: uploadStage.slotDir, namespace: uploadStageSftpNamespace } + { rootDir: uploadStage.slotDir, namespace: uploadStageSftpNamespace }, + prebuilt?.addons.dir ) await acquireInstallLock(conn, remoteRelayDir, hostPlatform, { signal: deploySignal }) @@ -534,20 +692,25 @@ async function deployAndLaunchRelayAttempt( } console.log('[ssh-relay] Upload complete') - onProgress?.('Installing native dependencies...') - console.log('[ssh-relay] Installing native dependencies...') - await installNativeDeps( - conn, - remoteRelayDir, - platform, - hostPlatform, - nodePath, - deploySignal, - [], - launchNamespace, - remoteHome - ) - console.log('[ssh-relay] Native deps installed') + if (pinnedContext) { + onProgress?.('Verifying relay runtime...') + await verifyPinnedRelayInstall(pinnedContext) + } else { + onProgress?.('Installing native dependencies...') + console.log('[ssh-relay] Installing native dependencies...') + await installNativeDeps( + conn, + remoteRelayDir, + platform, + hostPlatform, + nodePath, + deploySignal, + [], + launchNamespace, + remoteHome + ) + console.log('[ssh-relay] Native deps installed') + } // Why: mark complete but retain the lock until launch makes daemon liveness observable to cross-version GC. await finalizeInstall(conn, remoteRelayDir, hostPlatform, { @@ -693,6 +856,7 @@ async function deployAndLaunchRelayAttempt( gcOldRelayVersions(conn, remoteHome, remoteRelayDir, hostPlatform, { windowsNodePath: launched.nodePath, windowsSockNames: [relaySocketNameForInstanceId(relayInstanceId)], + nodePath: launched.nodePath, // Why pin rather than rely on the symlink alone: a deploy that fell back to a // per-directory install has no reference to show, and its key must still survive. nativeDepsCacheKeys: [ @@ -710,6 +874,14 @@ async function deployAndLaunchRelayAttempt( .then(() => gcRemoteRipgrepCache(conn, hostPlatform, remoteHome, { pinnedEntry: ripgrepEntry }) ) + // Same ordering reason as ripgrep: the version pass drops the refs that held old runtimes. + .then(() => + prebuilt?.kind === 'pinned-node' + ? gcRemoteNodeRuntimeStore(conn, hostPlatform, remoteHome, { + currentPins: [NODE_RUNTIME_ASSETS[prebuilt.target].executableSha256] + }) + : undefined + ) .then(() => true) .catch( (error) => @@ -749,7 +921,9 @@ async function uploadRelay( fullVersion: string, hostPlatform: RemoteHostPlatform, signal?: AbortSignal, - stage?: { rootDir: string; namespace?: RelayUploadStageNamespace } + stage?: { rootDir: string; namespace?: RelayUploadStageNamespace }, + /** Pinned-Node addons laid out relative to the relay dir. */ + localAddonsDir?: string ): Promise { const localRelayDir = getLocalRelayPath(platform) if (!localRelayDir || !existsSync(localRelayDir)) { @@ -768,12 +942,14 @@ async function uploadRelay( ) } - await uploadRelayDirectory(conn, localRelayDir, remoteDir, hostPlatform, { - signal, - sftpNamespace: stage?.namespace - ? relayUploadStageSftpNamespaceMapping(stage.namespace, hostPlatform, stage.rootDir) - : undefined - }) + for (const localDir of localAddonsDir ? [localRelayDir, localAddonsDir] : [localRelayDir]) { + await uploadRelayDirectory(conn, localDir, remoteDir, hostPlatform, { + signal, + sftpNamespace: stage?.namespace + ? relayUploadStageSftpNamespaceMapping(stage.namespace, hostPlatform, stage.rootDir) + : undefined + }) + } if (!isWindowsRemoteHost(hostPlatform)) { await execHostCommand( @@ -991,11 +1167,7 @@ async function repairInstalledNativeDeps( nodePath: string, homeRelativeRelayDir: string, signal?: AbortSignal -): Promise<{ - ownsInstallLock: boolean - gcClaimToken?: string - sftpNamespace?: RelayInstallNamespace -}> { +): Promise { const initialProbe = await probeRequiredNativeDeps( conn, remoteDir, @@ -1003,57 +1175,22 @@ async function repairInstalledNativeDeps( nodePath, signal ) - const lockResult = await tryAcquireRelayRepairLock(conn, remoteDir, hostPlatform, { signal }) - if (lockResult === 'gc') { - throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform) - } - if (lockResult === 'acquired') { - let stillInstalled: boolean - try { - stillInstalled = await isRelayAlreadyInstalled(conn, remoteDir, hostPlatform, { - rethrowSessionLimitErrors: true, - signal - }) - } catch (err) { - if (!isUnconfirmedSshCommandTermination(err)) { - await abandonInstall(conn, remoteDir, hostPlatform) - } - throw err - } - if (!stillInstalled) { - // Why: GC may finish its rename before our lock recreates the path; never trust probes made before this locked recheck. - await abandonInstall(conn, remoteDir, hostPlatform) - throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform) - } - } - const gcClaimToken = - lockResult === 'busy' || lockResult === 'error' - ? await acquireRelayLaunchGcFence(conn, remoteDir, hostPlatform, signal) - : undefined + const { lockResult, gcClaimToken } = await acquireInstalledRelayFence( + conn, + remoteDir, + hostPlatform, + signal + ) // Why: only a probe that answered may trigger repair; an unverifiable one launches as-is and the next reconnect re-probes. if (initialProbe.status !== 'blocked') { - // Why: even a healthy reconnect stays fenced until launch liveness is observable, or cross-version GC can rename after this probe. - if (lockResult !== 'acquired') { - return { ownsInstallLock: false, gcClaimToken } - } - try { - return { - ownsInstallLock: true, - sftpNamespace: await createRelayLaunchNamespace( - conn, - hostPlatform, - remoteDir, - homeRelativeRelayDir, - signal - ) - } - } catch (err) { - signal?.throwIfAborted() - console.warn( - `[ssh-relay] Launch namespace marker is unconfirmed at ${remoteDir}; deferring lock ownership to stale recovery` - ) - return { ownsInstallLock: !isUnconfirmedSshCommandTermination(err) } - } + return launchFenceWithoutRepair( + conn, + hostPlatform, + remoteDir, + homeRelativeRelayDir, + { lockResult, gcClaimToken }, + signal + ) } // Why: an already-installed relay can launch degraded, so native-deps repair is best-effort — lock contention and failures must not abort the connection. @@ -1105,6 +1242,105 @@ async function repairInstalledNativeDeps( } } +type RelayLaunchFence = { + ownsInstallLock: boolean + gcClaimToken?: string + sftpNamespace?: RelayInstallNamespace +} + +type InstalledRelayFence = { + lockResult: 'acquired' | 'busy' | 'error' + gcClaimToken?: string +} + +/** Hold an installed relay dir against cross-version GC until launch makes liveness observable. */ +async function acquireInstalledRelayFence( + conn: SshConnection, + remoteDir: string, + hostPlatform: RemoteHostPlatform, + signal?: AbortSignal +): Promise { + const lockResult = await tryAcquireRelayRepairLock(conn, remoteDir, hostPlatform, { signal }) + if (lockResult === 'gc') { + throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform) + } + if (lockResult === 'acquired') { + let stillInstalled: boolean + try { + stillInstalled = await isRelayAlreadyInstalled(conn, remoteDir, hostPlatform, { + rethrowSessionLimitErrors: true, + signal + }) + } catch (err) { + if (!isUnconfirmedSshCommandTermination(err)) { + await abandonInstall(conn, remoteDir, hostPlatform) + } + throw err + } + if (!stillInstalled) { + // Why: GC may finish its rename before our lock recreates the path; never trust probes made before this locked recheck. + await abandonInstall(conn, remoteDir, hostPlatform) + throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform) + } + } + const gcClaimToken = + lockResult === 'busy' || lockResult === 'error' + ? await acquireRelayLaunchGcFence(conn, remoteDir, hostPlatform, signal) + : undefined + return { lockResult, gcClaimToken } +} + +async function launchFenceWithoutRepair( + conn: SshConnection, + hostPlatform: RemoteHostPlatform, + remoteDir: string, + homeRelativeRelayDir: string, + { lockResult, gcClaimToken }: InstalledRelayFence, + signal?: AbortSignal +): Promise { + // Why: even a healthy reconnect stays fenced until launch liveness is observable, or cross-version GC can rename after this probe. + if (lockResult !== 'acquired') { + return { ownsInstallLock: false, gcClaimToken } + } + try { + return { + ownsInstallLock: true, + sftpNamespace: await createRelayLaunchNamespace( + conn, + hostPlatform, + remoteDir, + homeRelativeRelayDir, + signal + ) + } + } catch (err) { + signal?.throwIfAborted() + console.warn( + `[ssh-relay] Launch namespace marker is unconfirmed at ${remoteDir}; deferring lock ownership to stale recovery` + ) + return { ownsInstallLock: !isUnconfirmedSshCommandTermination(err) } + } +} + +/** A pinned-Node relay dir has no host-installed native deps to repair; only the launch fence applies. */ +async function fenceInstalledPinnedRelay( + conn: SshConnection, + remoteDir: string, + hostPlatform: RemoteHostPlatform, + homeRelativeRelayDir: string, + signal?: AbortSignal +): Promise { + const fence = await acquireInstalledRelayFence(conn, remoteDir, hostPlatform, signal) + return launchFenceWithoutRepair( + conn, + hostPlatform, + remoteDir, + homeRelativeRelayDir, + fence, + signal + ) +} + /** * Stamp this connection as the launch writer while it owns the install lock. * Confirmed marker failures fall back to shell-path credential generation. diff --git a/src/main/ssh/ssh-relay-endpoint-incumbent.ts b/src/main/ssh/ssh-relay-endpoint-incumbent.ts index 343f92cadecc..ee4d7d59eea7 100644 --- a/src/main/ssh/ssh-relay-endpoint-incumbent.ts +++ b/src/main/ssh/ssh-relay-endpoint-incumbent.ts @@ -69,7 +69,7 @@ const PROBE_END = 'ORCA-INCUMBENT-END' const CONNECT_PROBE_TIMEOUT_MS = 1000 // Why ES5 syntax: nodePath may be a host-resolved system node, not the bundled one. -const CONNECT_PROBE_JS = [ +export const RELAY_CONNECT_PROBE_JS = [ 'var s=require("net").connect(process.argv[1]);', 'var done=false;', 'function say(v){if(done)return;done=true;try{s.destroy()}catch(e){};', @@ -100,7 +100,7 @@ export function relayEndpointIncumbentProbeCommand(nodePath: string, sockPath: s `printf '%s\\n' ${shellEscape(PROBE_BEGIN)}`, 'if [ -S "$sock" ]; then', " printf 'PRESENT=yes\\n'", - ` listen=$("$node" -e ${shellEscape(CONNECT_PROBE_JS)} "$sock" 2>/dev/null) || listen=unknown`, + ` listen=$("$node" -e ${shellEscape(RELAY_CONNECT_PROBE_JS)} "$sock" 2>/dev/null) || listen=unknown`, ' [ -n "$listen" ] || listen=unknown', 'else', " printf 'PRESENT=no\\n'", diff --git a/src/main/ssh/ssh-relay-exec-command.ts b/src/main/ssh/ssh-relay-exec-command.ts index bec41c8d43e2..e7d5fea353ce 100644 --- a/src/main/ssh/ssh-relay-exec-command.ts +++ b/src/main/ssh/ssh-relay-exec-command.ts @@ -1,3 +1,4 @@ +import type { Readable } from 'node:stream' import type { ClientChannel } from 'ssh2' import type { SshConnection } from './ssh-connection' import { createSshOperationAbortError, type SshExecOptions } from './ssh-connection-utils' @@ -18,6 +19,8 @@ type ExecCommandOptions = SshExecOptions & { // folding stderr into stdout, where it would match the probe's own token strings. // On the system-ssh transport this stream also carries local OpenSSH noise; log-only. onStderr?: (stderr: string) => void + /** Streamed into the command's stdin, then EOF. A read error terminates the command. */ + stdin?: Readable } type SshCommandTerminationError = Error & { @@ -48,7 +51,7 @@ export async function execCommand( command: string, options?: ExecCommandOptions ): Promise { - const { timeoutMs = EXEC_TIMEOUT_MS, onStderr, ...execOptions } = options ?? {} + const { timeoutMs = EXEC_TIMEOUT_MS, onStderr, stdin, ...execOptions } = options ?? {} const signal = options?.signal if (signal?.aborted) { throw createSshOperationAbortError() @@ -82,6 +85,11 @@ export async function execCommand( channel.off('data', onStdoutData) channel.stderr.off('data', onStderrData) channel.off('close', onClose) + if (stdin) { + stdin.off('error', fail) + stdin.unpipe(channel.stdin) + stdin.destroy() + } } const settle = (fn: typeof resolve | typeof reject, val: string | Error): void => { if (settled) { @@ -193,6 +201,11 @@ export async function execCommand( channel.on('data', onStdoutData) channel.stderr.on('data', onStderrData) channel.on('close', onClose) + if (stdin) { + stdin.on('error', fail) + // Why `.stdin`: ssh2 aliases it to the channel, and a system-ssh channel only ends it there. + stdin.pipe(channel.stdin) + } if (signal?.aborted) { onAbort() } diff --git a/src/main/ssh/ssh-relay-host-node-addons.test.ts b/src/main/ssh/ssh-relay-host-node-addons.test.ts new file mode 100644 index 000000000000..63ea3bc8c0ca --- /dev/null +++ b/src/main/ssh/ssh-relay-host-node-addons.test.ts @@ -0,0 +1,117 @@ +import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin' +import type { SshConnection } from './ssh-connection' +import { HOST_NODE_RELAY_RUNTIME_KEY, planHostNodeAddonRelay } from './ssh-relay-host-node-addons' +import { + pinnedNodeRelayFullVersion, + pinnedRelayAddonFiles, + RELAY_RUNTIME_REF_PREFIX +} from './ssh-relay-pinned-node' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const conn = {} as SshConnection +const linux = getRemoteHostPlatform('linux-x64') +const facts = { target: 'linux-x64-glibc' as const, glibc: { major: 2, minor: 31 } } +const dirs: string[] = [] + +function fakeOrcadSlot(): string { + const dir = mkdtempSync(join(tmpdir(), 'orcad-slot-')) + dirs.push(dir) + for (const file of pinnedRelayAddonFiles('linux-x64-glibc')) { + const path = join(dir, ...file.split('/')) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, file) + } + return dir +} + +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +const hostNode = { + nodePath: '/usr/bin/node', + facts: { version: { major: 20, minor: 11 }, napi: 9 } +} + +describe('planHostNodeAddonRelay (rung C)', () => { + it('stages the slot addons without a pinned runtime ref, under a version apart from rung A', async () => { + const slot = fakeOrcadSlot() + const resolveHostNode = vi.fn().mockResolvedValue(hostNode) + const plan = await planHostNodeAddonRelay({ + conn, + host: linux, + facts, + baseVersion: '0.1.0+abcdef012345', + materializeOrcad: async () => slot, + resolveHostNode + }) + try { + expect(resolveHostNode).toHaveBeenCalledWith(conn, 8, { signal: undefined }) + expect(plan.nodePath).toBe('/usr/bin/node') + expect(plan.fullVersion).toBe( + pinnedNodeRelayFullVersion( + '0.1.0+abcdef012345', + HOST_NODE_RELAY_RUNTIME_KEY, + plan.addons.digest + ) + ) + expect(plan.fullVersion).not.toBe( + pinnedNodeRelayFullVersion( + '0.1.0+abcdef012345', + NODE_RUNTIME_ASSETS['linux-x64-glibc'].executableSha256, + plan.addons.digest + ) + ) + expect(readdirSync(plan.addons.dir).some((f) => f.startsWith(RELAY_RUNTIME_REF_PREFIX))).toBe( + false + ) + } finally { + await plan.addons.dispose() + } + }) + + it('refuses with host_node_missing when no qualifying host Node answered', async () => { + await expect( + planHostNodeAddonRelay({ + conn, + host: linux, + facts, + baseVersion: '0.1.0+abcdef012345', + resolveHostNode: vi.fn().mockResolvedValue(null) + }) + ).rejects.toMatchObject({ reason: 'host_node_missing' }) + }) + + it('refuses below the addons glibc floor before probing for a Node', async () => { + const resolveHostNode = vi.fn() + await expect( + planHostNodeAddonRelay({ + conn, + host: linux, + facts: { ...facts, glibc: { major: 2, minor: 17 } }, + baseVersion: '0.1.0+abcdef012345', + resolveHostNode + }) + ).rejects.toMatchObject({ reason: 'libc_floor' }) + expect(resolveHostNode).not.toHaveBeenCalled() + }) + + it('lets an unanswered host Node probe propagate instead of stepping down', async () => { + const lost = new Error('channel lost') + await expect( + planHostNodeAddonRelay({ + conn, + host: linux, + facts, + baseVersion: '0.1.0+abcdef012345', + resolveHostNode: vi.fn().mockRejectedValue(lost) + }) + ).rejects.toBe(lost) + }) +}) diff --git a/src/main/ssh/ssh-relay-host-node-addons.ts b/src/main/ssh/ssh-relay-host-node-addons.ts new file mode 100644 index 000000000000..37a6f9032ebf --- /dev/null +++ b/src/main/ssh/ssh-relay-host-node-addons.ts @@ -0,0 +1,110 @@ +/** + * Rung C of the relay runtime ladder (design D6): the relay runs on the host's own Node + * (>= 18) with Orca's prebuilt N-API addons uploaded beside it. No npm, no compiler. + */ +import { ORCAD_ADDON_NAPI_VERSION } from '../../shared/orcad-artifacts' +import type { ServerTarget } from '../../shared/node-runtime-pin' +import { materializeOrcadArtifact } from './orcad-artifact-materializer' +import type { GlibcVersion, OrcadDeploymentTargetFacts } from './orcad-deployment-target' +import type { SshConnection } from './ssh-connection' +import { + isGlibcBelow, + PINNED_NODE_GLIBC_FLOOR, + PinnedRelayFallbackError, + pinnedNodeRelayFullVersion, + stagePinnedRelayAddons, + type PinnedRelayAddons, + type PinnedRelayPlan +} from './ssh-relay-pinned-node' +import { resolveRemoteHostNodeForAddons } from './ssh-remote-node-addon-resolution' +import type { HostNodeAddonFacts } from './ssh-remote-node-toolchain-probe' +import type { RemoteHostPlatform } from './ssh-remote-platform' +import { tmpdir } from 'node:os' + +/** Folded into the relay version in place of a runtime hash, so C never shares A's dir. */ +export const HOST_NODE_RELAY_RUNTIME_KEY = 'host-node' + +/** The server slots' addons are built against this glibc, whichever Node loads them. */ +export const RELAY_ADDON_GLIBC_FLOOR: GlibcVersion = PINNED_NODE_GLIBC_FLOOR + +export type HostNodeAddonRelayPlan = { + kind: 'host-node-addons' + target: ServerTarget + glibc: GlibcVersion | null + fullVersion: string + addons: PinnedRelayAddons + nodePath: string + hostNode: HostNodeAddonFacts +} + +/** A relay launched from uploaded prebuilt addons rather than a host npm install. */ +export type PrebuiltRelayPlan = PinnedRelayPlan | HostNodeAddonRelayPlan + +export async function planHostNodeAddonRelay(options: { + conn: SshConnection + host: RemoteHostPlatform + facts: OrcadDeploymentTargetFacts + baseVersion: string + signal?: AbortSignal + materializeOrcad?: (target: ServerTarget, signal?: AbortSignal) => Promise + resolveHostNode?: typeof resolveRemoteHostNodeForAddons +}): Promise { + const { conn, host, facts, signal } = options + if (host.os === 'win32') { + throw new PinnedRelayFallbackError( + 'windows_host_unsupported', + 'Windows hosts keep the host-Node relay for now' + ) + } + if (facts.glibc && isGlibcBelow(facts.glibc, RELAY_ADDON_GLIBC_FLOOR)) { + throw new PinnedRelayFallbackError( + 'libc_floor', + `host glibc ${facts.glibc.major}.${facts.glibc.minor} is below the prebuilt addons' floor` + ) + } + const resolveHostNode = options.resolveHostNode ?? resolveRemoteHostNodeForAddons + const hostNode = await resolveHostNode(conn, ORCAD_ADDON_NAPI_VERSION, { signal }) + if (!hostNode) { + throw new PinnedRelayFallbackError( + 'host_node_missing', + `no host Node.js 18+ with N-API ${ORCAD_ADDON_NAPI_VERSION} was found` + ) + } + let addons: PinnedRelayAddons + try { + const materialize = + options.materializeOrcad ?? ((t, s) => materializeOrcadArtifact(t, { signal: s })) + addons = await stagePinnedRelayAddons( + await materialize(facts.target, signal), + facts.target, + tmpdir(), + { + runtimeRef: false + } + ) + } catch (error) { + signal?.throwIfAborted() + throw new PinnedRelayFallbackError( + 'artifacts_unavailable', + error instanceof Error ? error.message : String(error) + ) + } + try { + return { + kind: 'host-node-addons', + target: facts.target, + glibc: facts.glibc, + fullVersion: pinnedNodeRelayFullVersion( + options.baseVersion, + HOST_NODE_RELAY_RUNTIME_KEY, + addons.digest + ), + addons, + nodePath: hostNode.nodePath, + hostNode: hostNode.facts + } + } catch (error) { + await addons.dispose() + throw error + } +} diff --git a/src/main/ssh/ssh-relay-install-lock.ts b/src/main/ssh/ssh-relay-install-lock.ts index 3831851cbe93..eaa759bcf9a4 100644 --- a/src/main/ssh/ssh-relay-install-lock.ts +++ b/src/main/ssh/ssh-relay-install-lock.ts @@ -72,9 +72,19 @@ export async function acquireInstallLock( conn: SshConnection, remoteRelayDir: string, host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, - options?: { signal?: AbortSignal } + options?: { + signal?: AbortSignal + lockName?: string + /** False for a lock whose directory is not a relay version dir, so no GC claim can name it. */ + relayGcClaim?: boolean + } ): Promise { - const lockDir = joinRemotePath(host, remoteRelayDir, RELAY_INSTALL_LOCK_NAME) + const lockDir = joinRemotePath(host, remoteRelayDir, options?.lockName ?? RELAY_INSTALL_LOCK_NAME) + const relayGcClaim = options?.relayGcClaim ?? true + const isClaimed = (): Promise => + relayGcClaim + ? isRelayGcClaimed(conn, remoteRelayDir, host, options?.signal) + : Promise.resolve(false) const start = Date.now() let lastStaleCheckAt = Number.NEGATIVE_INFINITY @@ -82,7 +92,9 @@ export async function acquireInstallLock( while (true) { // Why: a crashed GC can leave the stable sibling claim behind. The shared // waiter recovers stale claims instead of polling that orphan forever. - await waitForRelayGcClaimRelease(conn, remoteRelayDir, host, options?.signal) + if (relayGcClaim) { + await waitForRelayGcClaimRelease(conn, remoteRelayDir, host, options?.signal) + } options?.signal?.throwIfAborted() await execHostCommand(conn, host, acquireInstallLockParentCommand(host, remoteRelayDir), { signal: options?.signal @@ -94,12 +106,7 @@ export async function acquireInstallLock( if (result.trim().endsWith('OK')) { // Why: GC may claim the sibling path between our first probe and lock // creation. Recheck while holding the in-tree lock; one side backs off. - const claimedAfterAcquire = await isRelayGcClaimed( - conn, - remoteRelayDir, - host, - options?.signal - ).catch((err) => { + const claimedAfterAcquire = await isClaimed().catch((err) => { if (isUnconfirmedSshCommandTermination(err)) { throw err } @@ -142,12 +149,7 @@ export async function acquireInstallLock( if (steal.trim().endsWith('OK')) { const reason = steal.trim().endsWith('REBOOT_OK') ? 'previous-boot' : 'stale' console.warn(`[ssh-relay] Stealing ${reason} install lock at ${lockDir}`) - const claimedAfterSteal = await isRelayGcClaimed( - conn, - remoteRelayDir, - host, - options?.signal - ).catch((err) => { + const claimedAfterSteal = await isClaimed().catch((err) => { if (isUnconfirmedSshCommandTermination(err)) { throw err } diff --git a/src/main/ssh/ssh-relay-install-transfers-exec-fallback.test.ts b/src/main/ssh/ssh-relay-install-transfers-exec-fallback.test.ts new file mode 100644 index 000000000000..7653c42feb94 --- /dev/null +++ b/src/main/ssh/ssh-relay-install-transfers-exec-fallback.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshConnection } from './ssh-connection' +import { createSshOperationAbortError } from './ssh-connection-utils' +import { + uploadDirectoryViaExecStdin, + writeStringViaExecStdin +} from './ssh-exec-stdin-file-transfer' +import { + classifySftpFailureForExecFallback, + uploadRelayDirectory, + writeRelayFile +} from './ssh-relay-install-transfers' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +vi.mock('./ssh-exec-stdin-file-transfer', () => ({ + uploadDirectoryViaExecStdin: vi.fn(async () => {}), + writeStringViaExecStdin: vi.fn(async () => {}) +})) + +const posix = getRemoteHostPlatform('linux-x64') +const windows = getRemoteHostPlatform('win32-x64') +const subsystemRefused = (): Error => new Error('Unable to start subsystem: sftp') +const sandboxed = (): Error => Object.assign(new Error('No such file'), { code: 2 }) +const unconfirmed = (): Error => + Object.assign(new Error('SSH command timed out'), { sshChannelCloseConfirmed: false }) + +function connection( + failure: () => Error, + overrides: Partial unknown>> = {} +): SshConnection & { + uploadDirectory: ReturnType + writeFile: ReturnType +} { + const conn = { + uploadDirectory: vi.fn(async () => { + throw failure() + }), + writeFile: vi.fn(async () => { + throw failure() + }), + usesSystemSshTransport: () => false, + getConnectGeneration: () => 1, + ...overrides + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the transfers only use the members defined above. + return conn as unknown as SshConnection & { + uploadDirectory: ReturnType + writeFile: ReturnType + } +} + +beforeEach(() => { + vi.mocked(uploadDirectoryViaExecStdin).mockClear() + vi.mocked(writeStringViaExecStdin).mockClear() + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +describe('classifySftpFailureForExecFallback', () => { + it('accepts only the host refusing or sandboxing SFTP', () => { + expect(classifySftpFailureForExecFallback(subsystemRefused())).toBe('sftp-unavailable') + expect( + classifySftpFailureForExecFallback( + new Error('Received exit code 127 while establishing SFTP session') + ) + ).toBe('sftp-unavailable') + expect(classifySftpFailureForExecFallback(sandboxed())).toBe('sftp-sandboxed') + }) + + it('never treats unverifiable transport loss or a refusal of the write itself as a verdict', () => { + for (const error of [ + unconfirmed(), + Object.assign(subsystemRefused(), { sshChannelCloseConfirmed: false }), + new Error('Received unexpected SFTP session termination'), + new Error('SSH SFTP channel timed out'), + new Error('Not connected'), + createSshOperationAbortError(), + Object.assign(new Error('Permission denied'), { code: 3 }), + 'Unable to start subsystem: sftp' + ]) { + expect(classifySftpFailureForExecFallback(error)).toBeNull() + } + }) +}) + +describe('relay install transfer fallback selection', () => { + it('streams over exec stdin when the subsystem is refused, and skips SFTP for the rest of the connect', async () => { + const conn = connection(subsystemRefused) + await uploadRelayDirectory(conn, '/local/relay', '/h/.orca-remote/stage/payload', posix) + await writeRelayFile(conn, posix, '/h/.orca-remote/stage/payload/.version', '0.1.0+abc') + + expect(uploadDirectoryViaExecStdin).toHaveBeenCalledWith( + conn, + '/local/relay', + '/h/.orca-remote/stage/payload', + posix, + { signal: undefined } + ) + expect(writeStringViaExecStdin).toHaveBeenCalledWith( + conn, + '/h/.orca-remote/stage/payload/.version', + '0.1.0+abc', + { signal: undefined } + ) + expect(conn.writeFile).not.toHaveBeenCalled() + }) + + it('asks SFTP again after a reconnect', async () => { + let generation = 1 + const conn = connection(subsystemRefused, { getConnectGeneration: () => generation }) + await uploadRelayDirectory(conn, '/l', '/h/r', posix) + generation = 2 + await uploadRelayDirectory(conn, '/l', '/h/r', posix) + expect(conn.uploadDirectory).toHaveBeenCalledTimes(2) + }) + + it('falls back for a chrooted subsystem without remembering it as a host verdict', async () => { + const conn = connection(sandboxed) + await uploadRelayDirectory(conn, '/l', '/h/r', posix) + await uploadRelayDirectory(conn, '/l', '/h/r', posix) + expect(conn.uploadDirectory).toHaveBeenCalledTimes(2) + expect(uploadDirectoryViaExecStdin).toHaveBeenCalledTimes(2) + }) + + it('surfaces unverifiable transport loss instead of retrying down another path', async () => { + const lost = unconfirmed() + const conn = connection(() => lost) + await expect(uploadRelayDirectory(conn, '/l', '/h/r', posix)).rejects.toBe(lost) + expect(uploadDirectoryViaExecStdin).not.toHaveBeenCalled() + }) + + it('does not fall back when the caller aborted', async () => { + const controller = new AbortController() + const conn = connection(() => { + controller.abort() + return subsystemRefused() + }) + await expect( + uploadRelayDirectory(conn, '/l', '/h/r', posix, { signal: controller.signal }) + ).rejects.toThrow() + expect(uploadDirectoryViaExecStdin).not.toHaveBeenCalled() + }) + + it('keeps the sandbox diagnosis where exec stdin cannot apply', async () => { + await expect( + uploadRelayDirectory(connection(sandboxed), '/l', 'C:/Users/u/r', windows) + ).rejects.toThrow(/SFTP subsystem sees a different filesystem/) + await expect( + uploadRelayDirectory( + connection(sandboxed, { usesSystemSshTransport: () => true }), + '/l', + '/h/r', + posix + ) + ).rejects.toThrow(/SFTP subsystem sees a different filesystem/) + expect(uploadDirectoryViaExecStdin).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ssh/ssh-relay-install-transfers.ts b/src/main/ssh/ssh-relay-install-transfers.ts index 6f8ede57984d..4bfa041428f5 100644 --- a/src/main/ssh/ssh-relay-install-transfers.ts +++ b/src/main/ssh/ssh-relay-install-transfers.ts @@ -1,7 +1,8 @@ // Relay-install SFTP writes. Each helper prefers the SshConnection transfer // method and otherwise drives one SFTP session itself, because deploy and // native-dependency tests pass partial connection doubles. Both routes share the -// same namespace resolution, abort race, and one-shot session teardown. +// same namespace resolution, abort race, and one-shot session teardown. When the +// host definitively refuses SFTP, POSIX hosts fall back to exec-channel stdin. import type { SFTPWrapper } from 'ssh2' import type { SshConnection } from './ssh-connection' @@ -12,7 +13,12 @@ import { resolveSftpTransferPathIfMapped, type SftpNamespacePathMapping } from './sftp-namespace-resolution' -import type { RemoteHostPlatform } from './ssh-remote-platform' +import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' +import { + uploadDirectoryViaExecStdin, + writeStringViaExecStdin +} from './ssh-exec-stdin-file-transfer' +import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' import { describeSandboxedSftpFailure, isSandboxedSftpNamespaceError, @@ -31,8 +37,16 @@ export async function uploadRelayDirectory( hostPlatform: RemoteHostPlatform, options?: RelayTransferOptions ): Promise { - await withSandboxedSftpDiagnosis(shellRemoteDir, () => - uploadRelayDirectoryTransfer(conn, localRelayDir, shellRemoteDir, hostPlatform, options) + await transferWithExecStdinFallback( + conn, + hostPlatform, + shellRemoteDir, + options?.signal, + () => uploadRelayDirectoryTransfer(conn, localRelayDir, shellRemoteDir, hostPlatform, options), + () => + uploadDirectoryViaExecStdin(conn, localRelayDir, shellRemoteDir, hostPlatform, { + signal: options?.signal + }) ) } @@ -70,8 +84,13 @@ export async function writeRelayFile( contents: string, options?: RelayTransferOptions ): Promise { - await withSandboxedSftpDiagnosis(shellRemotePath, () => - writeRelayFileTransfer(conn, hostPlatform, shellRemotePath, contents, options) + await transferWithExecStdinFallback( + conn, + hostPlatform, + shellRemotePath, + options?.signal, + () => writeRelayFileTransfer(conn, hostPlatform, shellRemotePath, contents, options), + () => writeStringViaExecStdin(conn, shellRemotePath, contents, { signal: options?.signal }) ) } @@ -129,22 +148,75 @@ async function runSftpFallbackTransfer( } } +export type SftpExecFallbackReason = 'sftp-unavailable' | 'sftp-sandboxed' + /** - * A jump host whose SFTP subsystem is chrooted answers a home path with - * SSH_FX_NO_SUCH_FILE even though the shell channel resolves it (#15479). SFTP is the - * only install route on the bundled-ssh2 transport, so say what the host did rather - * than surfacing a bare "file does not exist". + * Only the host's own answer about SFTP selects exec stdin. A lost transport, a timeout or an + * abort is unverifiable and must surface, never retry down another path. */ -async function withSandboxedSftpDiagnosis( +export function classifySftpFailureForExecFallback(error: unknown): SftpExecFallbackReason | null { + if (!(error instanceof Error) || isUnconfirmedSshCommandTermination(error)) { + return null + } + // ssh2's words when sshd refuses the subsystem (no `Subsystem sftp`, e.g. #12868 Synology). + if (error.message === 'Unable to start subsystem: sftp') { + return 'sftp-unavailable' + } + // sshd accepted the subsystem but its sftp-server exited before the handshake. + if (/^Received exit code \d+ while establishing SFTP session$/.test(error.message)) { + return 'sftp-unavailable' + } + // A shell-created path the SFTP view cannot see: a chrooted subsystem (#15479). + if (isSandboxedSftpNamespaceError(error)) { + return 'sftp-sandboxed' + } + return null +} + +/** Exec stdin needs a POSIX shell; the system-SSH transport never used SFTP for these writes. */ +function canFallBackToExecStdin(conn: SshConnection, hostPlatform: RemoteHostPlatform): boolean { + return !isWindowsRemoteHost(hostPlatform) && conn.usesSystemSshTransport?.() !== true +} + +// Why per connect generation: a refused subsystem is a host verdict, but a reconnect may reach +// a different host behind the same alias. +const sftpUnavailableGeneration = new WeakMap() + +function connectGeneration(conn: SshConnection): number { + return conn.getConnectGeneration?.() ?? 0 +} + +async function transferWithExecStdinFallback( + conn: SshConnection, + hostPlatform: RemoteHostPlatform, remotePath: string, - transfer: () => Promise -): Promise { + signal: AbortSignal | undefined, + sftpTransfer: () => Promise, + execStdinTransfer: () => Promise +): Promise { + const fallbackAllowed = canFallBackToExecStdin(conn, hostPlatform) + if (fallbackAllowed && sftpUnavailableGeneration.get(conn) === connectGeneration(conn)) { + await execStdinTransfer() + return + } try { - return await transfer() + await sftpTransfer() + return } catch (error) { - if (isSandboxedSftpNamespaceError(error)) { - throw describeSandboxedSftpFailure(error, remotePath) + const reason = fallbackAllowed ? classifySftpFailureForExecFallback(error) : null + if (!reason) { + if (isSandboxedSftpNamespaceError(error)) { + throw describeSandboxedSftpFailure(error, remotePath) + } + throw error } - throw error + signal?.throwIfAborted() + if (reason === 'sftp-unavailable') { + sftpUnavailableGeneration.set(conn, connectGeneration(conn)) + } + console.warn( + `[ssh-relay] SFTP ${reason === 'sftp-unavailable' ? 'is unavailable' : 'cannot see the install path'} (${error instanceof Error ? error.message : String(error)}); streaming ${remotePath} over exec stdin` + ) } + await execStdinTransfer() } diff --git a/src/main/ssh/ssh-relay-opencode-pinned-node.test.ts b/src/main/ssh/ssh-relay-opencode-pinned-node.test.ts new file mode 100644 index 000000000000..c61f32f41165 --- /dev/null +++ b/src/main/ssh/ssh-relay-opencode-pinned-node.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeRuntimeStore from './orcad-remote-node-runtime' +import type * as RuntimeCommands from './ssh-relay-opencode-runtime-commands' + +const mocks = vi.hoisted(() => ({ + target: vi.fn(), + archive: vi.fn(), + executable: vi.fn(), + ensure: vi.fn(), + probe: vi.fn((_args: unknown) => 'cache-probe') +})) +vi.mock('./orcad-deployment-target', () => ({ resolveOrcadDeploymentTarget: mocks.target })) +vi.mock('./pinned-runtime-materializer', () => ({ + materializeNodeRuntimeArchive: mocks.archive, + materializeCachedNodeRuntime: mocks.executable +})) +vi.mock('./orcad-remote-node-runtime', async (original) => ({ + ...(await original()), + ensureRemoteOrcadNodeRuntime: mocks.ensure +})) +vi.mock('./ssh-relay-opencode-runtime-commands', async (original) => ({ + ...(await original()), + probeOpenCodeRuntimeCacheCommand: mocks.probe +})) + +import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin' +import type { SshConnection } from './ssh-connection' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { OPENCODE_RUNTIME_RESULT } from './ssh-relay-opencode-runtime-commands' +import { preparePinnedNodeForVault } from './ssh-relay-opencode-pinned-node' + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every remote call is mocked; the connection is only passed through. +const conn = {} as SshConnection +const frame = (status: string, executable?: string) => + `${OPENCODE_RUNTIME_RESULT}${JSON.stringify({ status, executable })}\n` + +function prepare(platform: 'linux-x64' | 'win32-x64', exec: (command: string) => Promise) { + const relayDir = `${platform === 'win32-x64' ? 'C:/Users/ada' : '/home/ada'}/.orca-remote/relay-build` + return preparePinnedNodeForVault({ + conn, + host: getRemoteHostPlatform(platform), + nodePath: 'node', + relayDir, + cacheRoot: '/cache', + referencePath: `${relayDir}/opencode-sqlite-runtime.json`, + signal: new AbortController().signal, + exec, + remote: (operation) => operation() + }) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('pinned Node for the SSH vault reader', () => { + it('uses the shared runtimes/ store on POSIX hosts and fetches the archive only on demand', async () => { + mocks.target.mockResolvedValue('linux-x64-glibc') + mocks.ensure.mockImplementation(async (options) => { + expect(options).toMatchObject({ slotDir: '/home/ada/.orca-remote/relay-build' }) + await options.archivePath() + return { executable: '/home/ada/.orca-remote/runtimes/node-x/bin/node', transfer: 'cached' } + }) + mocks.archive.mockResolvedValue('/cache/node.tar.gz') + + expect(await prepare('linux-x64', vi.fn())).toEqual({ + executable: '/home/ada/.orca-remote/runtimes/node-x/bin/node' + }) + expect(mocks.archive).toHaveBeenCalledWith('linux-x64-glibc', '/cache', expect.any(Object)) + expect(mocks.executable).not.toHaveBeenCalled() + }) + + it('hands Windows hosts a verified node.exe under the same store layout', async () => { + mocks.target.mockResolvedValue('win32-x64') + mocks.executable.mockResolvedValue('/cache/node/sha/node.exe') + const expected = NODE_RUNTIME_ASSETS['win32-x64'].executableSha256 + const exec = vi.fn(async () => frame('missing')) + + const result = await prepare('win32-x64', exec) + + const executable = `C:/Users/ada/.orca-remote/runtimes/node-${expected}/node.exe` + expect(result).toEqual({ + executable, + upload: { localRuntime: '/cache/node/sha/node.exe', expectedHash: expected } + }) + expect(mocks.probe).toHaveBeenCalledWith( + expect.objectContaining({ executable, expectedHash: expected }) + ) + expect(mocks.ensure).not.toHaveBeenCalled() + expect(mocks.archive).not.toHaveBeenCalled() + }) + + it('reuses a Windows runtime the host already verified', async () => { + mocks.target.mockResolvedValue('win32-x64') + const cached = 'C:/Users/ada/.orca-remote/runtimes/node-x/repair-1/node.exe' + expect(await prepare('win32-x64', async () => frame('ready', cached))).toEqual({ + executable: cached + }) + expect(mocks.executable).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ssh/ssh-relay-opencode-pinned-node.ts b/src/main/ssh/ssh-relay-opencode-pinned-node.ts new file mode 100644 index 000000000000..7f4f90adcab7 --- /dev/null +++ b/src/main/ssh/ssh-relay-opencode-pinned-node.ts @@ -0,0 +1,107 @@ +import { join } from 'node:path' +import { getAppEnvironment } from '../../shared/app-environment' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { NODE_RUNTIME_ASSETS, type ServerTarget } from '../../shared/node-runtime-pin' +import { ORCAD_NODE_RUNTIME_WINDOWS_EXECUTABLE } from '../../shared/orcad-artifacts' +import type { SshConnection } from './ssh-connection' +import { resolveOrcadDeploymentTarget } from './orcad-deployment-target' +import { + ensureRemoteOrcadNodeRuntime, + remoteNodeRuntimeDir, + type RemoteRuntimeStep +} from './orcad-remote-node-runtime' +import { + materializeCachedNodeRuntime, + materializeNodeRuntimeArchive +} from './pinned-runtime-materializer' +import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { + parseOpenCodeRuntimeResult, + probeOpenCodeRuntimeCacheCommand +} from './ssh-relay-opencode-runtime-commands' + +const DOWNLOAD_TIMEOUT_MS = 180_000 +const downloads = new Map>() + +/** A verified local executable the caller still has to upload and promote on the host. */ +export type PinnedNodeVaultUpload = { localRuntime: string; expectedHash: string } + +/** + * The pinned Node for hosts whose own Node cannot read OpenCode's database (design D4a). + * POSIX hosts get it in the shared runtimes/ store; nothing here touches vault-sqlite/, which + * old relays' references still name. + */ +export async function preparePinnedNodeForVault(options: { + conn: SshConnection + host: RemoteHostPlatform + nodePath: string + relayDir: string + cacheRoot?: string + referencePath: string + signal: AbortSignal + exec: (command: string) => Promise + remote: RemoteRuntimeStep +}): Promise<{ executable: string; upload?: PinnedNodeVaultUpload }> { + const { conn, host, signal, exec } = options + const target = await resolveOrcadDeploymentTarget({ conn, host, signal, exec }) + const cacheRoot = + options.cacheRoot ?? join(getAppEnvironment().getPath('userData'), 'orcad-artifacts') + if (!isWindowsRemoteHost(host)) { + const { executable } = await ensureRemoteOrcadNodeRuntime({ + conn, + host, + slotDir: options.relayDir, + target, + archivePath: () => cachedRuntime('archive', target, cacheRoot, signal), + signal, + remoteStep: options.remote + }) + return { executable } + } + // Why a bare executable: Windows hosts get archive extraction with the upload path (design D5). + const expectedHash = NODE_RUNTIME_ASSETS[target].executableSha256 + const executable = joinRemotePath( + host, + remoteNodeRuntimeDir(host, options.relayDir, target), + ORCAD_NODE_RUNTIME_WINDOWS_EXECUTABLE + ) + const cached = parseOpenCodeRuntimeResult( + await exec( + probeOpenCodeRuntimeCacheCommand({ + host, + nodePath: options.nodePath, + executable, + expectedHash, + reference: options.referencePath + }) + ) + ) + if (cached.status === 'ready' && cached.executable) { + return { executable: cached.executable } + } + if (cached.status !== 'missing') { + throw new Error('The host did not confirm its SQLite runtime cache.') + } + const localRuntime = await cachedRuntime('executable', target, cacheRoot, signal) + signal.throwIfAborted() + return { executable, upload: { localRuntime, expectedHash } } +} + +function cachedRuntime( + kind: 'archive' | 'executable', + target: ServerTarget, + cacheRoot: string, + signal: AbortSignal +): Promise { + const key = `${cacheRoot}\0${kind}\0${target}` + let pending = downloads.get(key) + if (!pending) { + const materialize = + kind === 'archive' ? materializeNodeRuntimeArchive : materializeCachedNodeRuntime + pending = materialize(target, cacheRoot, { + signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) + }).finally(() => downloads.delete(key)) + downloads.set(key, pending) + } + return waitForPromiseWithSignal(pending, signal) +} diff --git a/src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts b/src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts index 72b5254c322f..3b45f9f71936 100644 --- a/src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts +++ b/src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts @@ -68,6 +68,41 @@ describe.skipIf(process.platform === 'win32')('host-owned SQLite setup commands' }) }) + it('sends a host Node without the SyncDatabase surface to the pinned runtime', async () => { + const home = await directory() + const data = join(home, '.local', 'share', 'opencode') + await mkdir(data, { recursive: true }) + await writeFile(join(data, 'opencode.db'), '') + // Node 22.13-22.15: DatabaseSync ships, the backup export does not. + const preload = join(home, 'node-22-13-sqlite.cjs') + await writeFile(preload, "delete require('node:sqlite').backup") + const result = await command(probeOpenCodeNodeSqliteCommand(host, nodePath, home), { + NODE_OPTIONS: `--require ${JSON.stringify(preload)}` + }) + expect(result.code, result.stderr).toBe(0) + expect(parseOpenCodeRuntimeResult(result.stdout)).toEqual({ status: 'unsupported' }) + }) + + it('ignores the experimental SQLite warning older Node prints to stderr', async () => { + const home = await directory() + const data = join(home, '.local', 'share', 'opencode') + await mkdir(data, { recursive: true }) + await writeFile(join(data, 'opencode.db'), '') + const preload = join(home, 'experimental-warning.cjs') + await writeFile( + preload, + "process.emitWarning('SQLite is an experimental feature and might change at any time','ExperimentalWarning')" + ) + const result = await command(probeOpenCodeNodeSqliteCommand(host, nodePath, home), { + NODE_OPTIONS: `--require ${JSON.stringify(preload)}` + }) + expect(result.stderr).toContain('ExperimentalWarning') + expect(parseOpenCodeRuntimeResult(result.stdout)).toEqual({ + status: 'ready', + executable: nodePath + }) + }) + it('stages, verifies by bytes, promotes and atomically publishes under quoted paths', async () => { const root = await directory() const stage = await reserveStage(root) diff --git a/src/main/ssh/ssh-relay-opencode-runtime-commands.ts b/src/main/ssh/ssh-relay-opencode-runtime-commands.ts index 14900f15e5bd..9026b9cb1fd9 100644 --- a/src/main/ssh/ssh-relay-opencode-runtime-commands.ts +++ b/src/main/ssh/ssh-relay-opencode-runtime-commands.ts @@ -1,3 +1,4 @@ +import { NODE_SQLITE_READER_API_SOURCE } from '../sqlite/node-sqlite-reader-api' import { shellEscape } from './ssh-connection-utils' import { posix, win32 } from 'node:path' import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' @@ -24,6 +25,7 @@ const HASH = `const fs=require('node:fs');const fsp=fs.promises;const path=requi async function hash(file){try{const digest=require('node:crypto').createHash('sha256');for await(const chunk of fs.createReadStream(file))digest.update(chunk);return digest.digest('hex')}catch(error){if(error.code==='ENOENT')return null;throw error}} ` +/** Host Node needs backup() too: 22.13-22.15 have DatabaseSync without it (design D4). */ export function probeOpenCodeNodeSqliteCommand( host: RemoteHostPlatform, nodePath: string, @@ -40,7 +42,8 @@ try{if(override&&override!==':memory:'){present=(await fs.stat(path.isAbsolute(o else if(!override){const directory=await fs.opendir(data);for await(const entry of directory){if(entry.isFile()&&/^opencode(?:-[A-Za-z0-9_.-]+)?\\.db$/.test(entry.name)){present=true;break}}}} catch(error){if(error.code!=='ENOENT'&&error.code!=='ENOTDIR')throw error} if(!present){send({status:'not-needed'});return} -let db;try{db=new(require('node:sqlite').DatabaseSync)(':memory:'); +let db;try{const sqlite=require('node:sqlite');if(!(${NODE_SQLITE_READER_API_SOURCE})(sqlite))throw Error('SQLite reader API missing'); +db=new sqlite.DatabaseSync(':memory:'); if(db.prepare('SELECT 1 AS ready').get().ready!==1)throw Error('SQLite read failed'); send({status:'ready',executable:process.execPath})}catch{send({status:'unsupported'})}finally{if(db)db.close()} })().catch(error=>{console.error(error.message);process.exitCode=1})`, diff --git a/src/main/ssh/ssh-relay-opencode-runtime.test.ts b/src/main/ssh/ssh-relay-opencode-runtime.test.ts index ce3da563b97c..9dfa82c14fce 100644 --- a/src/main/ssh/ssh-relay-opencode-runtime.test.ts +++ b/src/main/ssh/ssh-relay-opencode-runtime.test.ts @@ -25,21 +25,23 @@ vi.mock('./ssh-relay-install-transfers', () => ({ uploadRelayDirectory: mocks.upload, writeRelayFile: mocks.write })) -vi.mock('./orcad-bun-runtime-materializer', () => ({ - materializeCachedOrcadBunRuntime: mocks.materialize +vi.mock('./pinned-runtime-materializer', () => ({ + materializeNodeRuntimeArchive: mocks.materialize, + materializeCachedNodeRuntime: vi.fn() })) vi.mock('./orcad-deployment-target', () => ({ resolveOrcadDeploymentTarget: mocks.target })) import type { SshConnection } from './ssh-connection' import { getRemoteHostPlatform } from './ssh-remote-platform' -import { ORCAD_BUN_RELEASE_ASSETS } from '../../shared/orcad-bun-runtime' +import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin' import { ensureRemoteOpenCodeRuntime } from './ssh-relay-opencode-runtime' import { OPENCODE_RUNTIME_RESULT } from './ssh-relay-opencode-runtime-commands' const host = getRemoteHostPlatform('linux-x64') const remoteHome = '/home/ada' const relayDir = `${remoteHome}/.orca-remote/relay-build` -const binary = `${remoteHome}/.orca-remote/vault-sqlite/${ORCAD_BUN_RELEASE_ASSETS['linux-x64-glibc'].executableSha256}/bun` +const binary = `${remoteHome}/.orca-remote/runtimes/node-${NODE_RUNTIME_ASSETS['linux-x64-glibc'].executableSha256}/bin/node` +const archiveName = 'node-v24.21.0-linux-x64.tar.gz' let cacheRoot: string let runtime: string const frame = (status: string, executable?: string) => @@ -53,21 +55,21 @@ function hostCommandResult(command: string): string { } return `__ORCA_UPLOAD_STAGE_SLOT__${command.match(/\.sftp-namespace-[0-9a-f]{32}/)?.[0]}:slot-0` } + if (command.includes("/runtimes/.store-lock' 2>/dev/null")) { + return 'OK' + } if (command.includes('SELECT 1 AS ready')) { return frame('unsupported') } - if (command.includes('checksum mismatch')) { - if (mocks.checksumError) { - throw new Error('Uploaded SQLite runtime checksum mismatch') - } - return frame('ready', binary) + if (command.includes('ORCA_NODE_RUNTIME_EXTRACT_FAILED')) { + return mocks.checksumError ? 'ORCA_NODE_RUNTIME_HASH_MISMATCH' : 'ORCA_NODE_RUNTIME_READY' + } + if (command.includes('ORCA_NODE_RUNTIME_MISSING')) { + return mocks.warm ? 'ORCA_NODE_RUNTIME_READY' : 'ORCA_NODE_RUNTIME_MISSING' } if (command.includes('published')) { return frame('published') } - if (command.includes('status:')) { - return mocks.warm ? frame('ready', binary) : frame('missing') - } if (mocks.cleanupError && command.includes('claim_identity') && !command.includes('old=')) { throw Object.assign(new Error('Cleanup teardown is unconfirmed'), { sshChannelCloseConfirmed: false @@ -88,7 +90,7 @@ beforeEach(async () => { vi.resetAllMocks() vi.spyOn(console, 'warn').mockImplementation(() => {}) cacheRoot = await mkdtemp(join(tmpdir(), 'orca-vault-runtime-')) - runtime = join(cacheRoot, 'repair-1-orcad-bun') + runtime = join(cacheRoot, archiveName) await writeFile(runtime, 'verified runtime') mocks.materialize.mockResolvedValue(runtime) mocks.target.mockResolvedValue('linux-x64-glibc') @@ -106,7 +108,7 @@ afterEach(async () => { }) describe('SSH OpenCode runtime setup', () => { - it('publishes a capable existing Node without materializing or uploading Bun', async () => { + it('publishes a capable existing Node without materializing or uploading a runtime', async () => { mocks.exec.mockResolvedValueOnce(frame('ready', '/opt/node 24/bin/node')) expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe( 'ready' @@ -120,10 +122,13 @@ describe('SSH OpenCode runtime setup', () => { }) }) - it('uses the verified materializer cache after Node 18 fails the actual read probe', async () => { - mocks.upload.mockImplementation(async (_conn, localDir: string) => { - expect(await readdir(localDir)).toEqual(['bun']) - expect(await readFile(join(localDir, 'bun'), 'utf8')).toBe('verified runtime') + it('installs the pinned Node archive into the shared runtimes/ store after the read probe fails', async () => { + mocks.upload.mockImplementation(async (_conn, localDir: string, remoteDir: string) => { + expect(remoteDir).toMatch( + /\/\.orca-remote\/runtimes\/\.stage-node-[0-9a-f]{64}-[0-9a-f]{16}$/ + ) + expect(await readdir(localDir)).toEqual([archiveName]) + expect(await readFile(join(localDir, archiveName), 'utf8')).toBe('verified runtime') }) expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe( 'ready' @@ -134,37 +139,33 @@ describe('SSH OpenCode runtime setup', () => { expect(mocks.materialize).toHaveBeenCalledWith('linux-x64-glibc', cacheRoot, { signal: expect.any(AbortSignal) }) - expect(await readdir(cacheRoot)).toEqual(['repair-1-orcad-bun']) + expect(await readdir(cacheRoot)).toEqual([archiveName]) expect(JSON.parse(mocks.write.mock.calls[0][3])).toEqual({ protocol: 1, executable: binary }) + const commands: string[] = mocks.exec.mock.calls.map(([, command]) => command) + expect(commands.some((command) => command.includes('vault-sqlite'))).toBe(false) }) - it('reuses a remotely verified binary without downloading it again', async () => { + it('reuses a runtime the host store already verified without downloading it again', async () => { mocks.warm = true expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe( 'ready' ) expect(mocks.materialize).not.toHaveBeenCalled() expect(mocks.upload).not.toHaveBeenCalled() + expect(JSON.parse(mocks.write.mock.calls[0][3])).toEqual({ protocol: 1, executable: binary }) }) - it('uses one staging namespace for binary uploads and atomic reference writes', async () => { + it('writes the reference atomically through the reserved staging namespace', async () => { await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options()) - const uploadOptions = mocks.upload.mock.calls[0][4] const writeOptions = mocks.write.mock.calls[0][4] - expect(uploadOptions.sftpNamespace.homeRelativeNamespaceRoot).toMatch( + expect(writeOptions.sftpNamespace.homeRelativeNamespaceRoot).toMatch( /^\.orca-remote\/\.upload-stages\/slot-0$/ ) - expect(uploadOptions.sftpNamespace.shellProbePath).toBe( - writeOptions.sftpNamespace.shellProbePath - ) - expect(writeOptions.sftpNamespace.homeRelativePath).toBe( - `${uploadOptions.sftpNamespace.homeRelativePath}/opencode-sqlite-runtime.json` - ) + expect(writeOptions.sftpNamespace.homeRelativePath).toMatch(/\/opencode-sqlite-runtime\.json$/) }) it('skips namespace probing on system SSH', async () => { await ensureRemoteOpenCodeRuntime(connection(true), host, remoteHome, options()) - expect(mocks.upload.mock.calls[0][4].sftpNamespace).toBeUndefined() expect(mocks.write.mock.calls[0][4].sftpNamespace).toBeUndefined() }) @@ -193,7 +194,7 @@ describe('SSH OpenCode runtime setup', () => { expect(mocks.upload).toHaveBeenCalledTimes(2) }) - it('never publishes a reference after a failed remote checksum', async () => { + it('never publishes a reference after a failed remote runtime checksum', async () => { mocks.checksumError = true expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe( 'failed' @@ -214,7 +215,7 @@ describe('SSH OpenCode runtime setup', () => { }) ).toBe('teardown-unconfirmed') expect(mocks.write).not.toHaveBeenCalled() - expect(mocks.exec).toHaveBeenCalledTimes(4) + expect(mocks.exec).toHaveBeenCalledTimes(3) }) it('bounds even an unresponsive setup operation at 180 seconds', async () => { @@ -365,12 +366,13 @@ describe('SSH OpenCode runtime setup', () => { expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe( 'teardown-unconfirmed' ) - expect(mocks.exec).toHaveBeenCalledTimes(4) + expect(mocks.exec).toHaveBeenCalledTimes(3) + expect(mocks.exec.mock.calls.some(([, command]) => command.startsWith('rm -rf'))).toBe(false) expect(mocks.write).not.toHaveBeenCalled() expect(await ensureRemoteOpenCodeRuntime(conn, host, remoteHome, options())).toBe( 'teardown-unconfirmed' ) - expect(mocks.exec).toHaveBeenCalledTimes(4) + expect(mocks.exec).toHaveBeenCalledTimes(3) }) it('reports an unconfirmed stage cleanup to the deployment command queue', async () => { @@ -393,13 +395,14 @@ describe('SSH OpenCode runtime setup', () => { expect(mocks.upload).toHaveBeenCalledOnce() }) - it('fails optionally without uploading when all bounded stages are occupied', async () => { + it('fails optionally without publishing when all bounded stages are occupied', async () => { mocks.reservationError = true expect(await ensureRemoteOpenCodeRuntime(connection(), host, remoteHome, options())).toBe( 'failed' ) - expect(mocks.exec).toHaveBeenCalledTimes(4) - expect(mocks.upload).not.toHaveBeenCalled() + // Includes the store-lock round trips (mkdir, acquire, re-probe, release) around promotion. + expect(mocks.exec).toHaveBeenCalledTimes(11) + expect(mocks.write).not.toHaveBeenCalled() }) it('does not mistake an unanswered Node probe for an old runtime', async () => { diff --git a/src/main/ssh/ssh-relay-opencode-runtime.ts b/src/main/ssh/ssh-relay-opencode-runtime.ts index 83657e8b7f29..8ea6f8fa344b 100644 --- a/src/main/ssh/ssh-relay-opencode-runtime.ts +++ b/src/main/ssh/ssh-relay-opencode-runtime.ts @@ -1,12 +1,14 @@ import { randomBytes } from 'node:crypto' import { copyFile, link, mkdtemp, rm } from 'node:fs/promises' import { dirname, join } from 'node:path' -import { getAppEnvironment } from '../../shared/app-environment' import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' -import { ORCAD_BUN_RELEASE_ASSETS, type OrcadBunTarget } from '../../shared/orcad-bun-runtime' +import { ORCAD_NODE_RUNTIME_WINDOWS_EXECUTABLE } from '../../shared/orcad-artifacts' import type { SshConnection } from './ssh-connection' -import { resolveOrcadDeploymentTarget } from './orcad-deployment-target' -import { materializeCachedOrcadBunRuntime } from './orcad-bun-runtime-materializer' +import type { RemoteRuntimeStep } from './orcad-remote-node-runtime' +import { + preparePinnedNodeForVault, + type PinnedNodeVaultUpload +} from './ssh-relay-opencode-pinned-node' import { execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' import { uploadRelayDirectory, writeRelayFile } from './ssh-relay-install-transfers' import { @@ -25,13 +27,13 @@ import { } from './ssh-relay-upload-stage-commands' import { parseOpenCodeRuntimeResult, - probeOpenCodeRuntimeCacheCommand, probeOpenCodeNodeSqliteCommand, promoteOpenCodeRuntimeCommand, publishOpenCodeRuntimeReferenceCommand } from './ssh-relay-opencode-runtime-commands' const SETUP_TIMEOUT_MS = 180_000 +const RUNTIME_REFERENCE_NAME = 'opencode-sqlite-runtime.json' export type RemoteOpenCodeRuntimeOutcome = | 'ready' | 'not-needed' @@ -41,7 +43,6 @@ const installations = new WeakMap< SshConnection, { generation: number; byDirectory: Map> } >() -const downloads = new Map>() type SetupOptions = { nodePath: string @@ -49,7 +50,6 @@ type SetupOptions = { signal?: AbortSignal cacheRoot?: string } -type RemoteOperation = (operation: () => Promise) => Promise /** Optional companion setup; the host's relay and terminals never depend on it. */ export function ensureRemoteOpenCodeRuntime( @@ -83,7 +83,7 @@ export function ensureRemoteOpenCodeRuntime( throw new Error('SSH connection changed during SQLite runtime setup.') } } - const remote: RemoteOperation = async (operation) => { + const remote: RemoteRuntimeStep = async (operation) => { signal.throwIfAborted() assertCurrentGeneration() remotePending = true @@ -133,7 +133,7 @@ async function install( remoteHome: string, options: SetupOptions, signal: AbortSignal, - remote: RemoteOperation + remote: RemoteRuntimeStep ): Promise { const exec = async (command: string): Promise => { signal.throwIfAborted() @@ -156,40 +156,21 @@ async function install( throw new Error('The host did not complete its SQLite read probe.') } let executable = node.executable - let target: OrcadBunTarget | undefined - let localRuntime: string | undefined + let upload: PinnedNodeVaultUpload | undefined if (node.status === 'unsupported') { - target = await resolveOrcadDeploymentTarget({ conn, host, signal, exec }) - const expectedHash = ORCAD_BUN_RELEASE_ASSETS[target].executableSha256 - executable = joinRemotePath( + const pinned = await preparePinnedNodeForVault({ + conn, host, - remoteHome, - RELAY_REMOTE_DIR, - 'vault-sqlite', - expectedHash, - isWindowsRemoteHost(host) ? 'bun.exe' : 'bun' - ) - const cached = parseOpenCodeRuntimeResult( - await exec( - probeOpenCodeRuntimeCacheCommand({ - host, - nodePath: options.nodePath, - executable, - expectedHash, - reference: joinRemotePath(host, options.relayDir, 'opencode-sqlite-runtime.json') - }) - ) - ) - if (cached.status === 'ready' && cached.executable) { - executable = cached.executable - } else if (cached.status === 'missing') { - const cacheRoot = - options.cacheRoot ?? join(getAppEnvironment().getPath('userData'), 'orcad-artifacts') - localRuntime = await cachedRuntime(target, cacheRoot, signal) - signal.throwIfAborted() - } else { - throw new Error('The host did not confirm its SQLite runtime cache.') - } + nodePath: options.nodePath, + relayDir: options.relayDir, + cacheRoot: options.cacheRoot, + referencePath: joinRemotePath(host, options.relayDir, RUNTIME_REFERENCE_NAME), + signal, + exec, + remote + }) + executable = pinned.executable + upload = pinned.upload } if (!executable) { throw new Error('The host did not identify its SQLite executable.') @@ -213,10 +194,11 @@ async function install( : undefined let cleanupAllowed = true try { - if (target && localRuntime) { + if (upload) { + const { localRuntime } = upload const localStage = await mkdtemp(join(dirname(localRuntime), '.vault-upload-')) try { - const binaryName = isWindowsRemoteHost(host) ? 'bun.exe' : 'bun' + const binaryName = ORCAD_NODE_RUNTIME_WINDOWS_EXECUTABLE const localBinary = join(localStage, binaryName) await link(localRuntime, localBinary).catch(() => copyFile(localRuntime, localBinary)) signal.throwIfAborted() @@ -233,7 +215,7 @@ async function install( nodePath: options.nodePath, stagedBinary: joinRemotePath(host, stageDir, 'payload', binaryName), executable, - expectedHash: ORCAD_BUN_RELEASE_ASSETS[target].executableSha256, + expectedHash: upload.expectedHash, repairToken: token }) ) @@ -246,7 +228,7 @@ async function install( await rm(localStage, { recursive: true, force: true }).catch(() => {}) } } - const referenceName = 'opencode-sqlite-runtime.json' + const referenceName = RUNTIME_REFERENCE_NAME const stagedReference = joinRemotePath(host, stageDir, 'payload', referenceName) signal.throwIfAborted() await remote(() => @@ -280,19 +262,3 @@ async function install( } } } - -function cachedRuntime( - target: OrcadBunTarget, - cacheRoot: string, - signal: AbortSignal -): Promise { - const key = `${cacheRoot}\0${target}` - let pending = downloads.get(key) - if (!pending) { - pending = materializeCachedOrcadBunRuntime(target, cacheRoot, { - signal: AbortSignal.timeout(SETUP_TIMEOUT_MS) - }).finally(() => downloads.delete(key)) - downloads.set(key, pending) - } - return waitForPromiseWithSignal(pending, signal) -} diff --git a/src/main/ssh/ssh-relay-pinned-node-install.test.ts b/src/main/ssh/ssh-relay-pinned-node-install.test.ts new file mode 100644 index 000000000000..c0a373fa740d --- /dev/null +++ b/src/main/ssh/ssh-relay-pinned-node-install.test.ts @@ -0,0 +1,313 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { + ensureRemoteOrcadNodeRuntime, + REMOTE_NODE_RUNTIME_MISSING, + REMOTE_NODE_RUNTIME_READY, + RemoteNodeRuntimeSecurityModifiedError, + RemoteNodeRuntimeSelfTestError +} from './orcad-remote-node-runtime' +import { ensurePinnedRelayRuntime, verifyPinnedRelayInstall } from './ssh-relay-pinned-node-install' +import { + PinnedRelayFallbackError, + planPinnedNodeRelay, + resetPinnedRuntimeRefusalsForTests, + type PinnedRelayPlan +} from './ssh-relay-pinned-node' +import { runPinnedRuntimeSelfTest } from './ssh-relay-runtime-self-test' +import type { HostNodeAddonRelayPlan } from './ssh-relay-host-node-addons' +import { RelayRuntimeLadderRun } from './ssh-relay-runtime-resolution' +import { withRuntimeStoreLock } from './remote-node-runtime-store-lock' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { decodeRemotePowerShellScript } from './ssh-remote-powershell' + +vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn() })) +vi.mock('./orcad-remote-node-runtime', async (importOriginal) => ({ + ...(await importOriginal>()), + ensureRemoteOrcadNodeRuntime: vi.fn() +})) +vi.mock('./remote-node-runtime-store-lock', () => ({ + withRuntimeStoreLock: vi.fn((_conn, _host, _store, task: () => Promise) => task()) +})) +vi.mock('./ssh-relay-runtime-self-test', async (importOriginal) => ({ + ...(await importOriginal>()), + runPinnedRuntimeSelfTest: vi.fn() +})) + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: All connection operations are mocked. +const conn = {} as SshConnection +const host = getRemoteHostPlatform('linux-x64') +const plan: PinnedRelayPlan = { + kind: 'pinned-node', + target: 'linux-x64-glibc', + glibc: { major: 2, minor: 31 }, + fullVersion: '0.1.0+feedfacecafe', + addons: { dir: '/tmp/a', digest: 'd', dispose: async () => {} }, + runtimeArchive: async () => '/tmp/archive.tar.gz' +} +const context = { + conn, + host, + remoteRelayDir: '/home/u/.orca-remote/relay-0.1.0+feedfacecafe', + plan, + targetId: 'target-1' +} + +beforeEach(() => { + vi.mocked(execCommand).mockReset() + vi.mocked(ensureRemoteOrcadNodeRuntime).mockReset().mockResolvedValue({ + executable: '/home/u/.orca-remote/runtimes/node-test/bin/node', + transfer: 'uploaded' + }) + vi.mocked(runPinnedRuntimeSelfTest).mockReset() + resetPinnedRuntimeRefusalsForTests() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) +}) + +describe('ensurePinnedRelayRuntime', () => { + it('trusts the verified marker on the warm path and never re-uploads', async () => { + vi.mocked(execCommand).mockResolvedValueOnce(REMOTE_NODE_RUNTIME_READY) + await ensurePinnedRelayRuntime(context, true) + expect(ensureRemoteOrcadNodeRuntime).not.toHaveBeenCalled() + }) + + it('reinstalls a runtime that disappeared from under an installed relay', async () => { + vi.mocked(execCommand).mockResolvedValueOnce(REMOTE_NODE_RUNTIME_MISSING) + await ensurePinnedRelayRuntime(context, true) + expect(ensureRemoteOrcadNodeRuntime).toHaveBeenCalledWith( + expect.objectContaining({ slotDir: context.remoteRelayDir, target: 'linux-x64-glibc' }) + ) + }) + + it('turns a classified runtime refusal into a remembered fallback', async () => { + vi.mocked(ensureRemoteOrcadNodeRuntime).mockRejectedValueOnce( + new RemoteNodeRuntimeSelfTestError(126, 'sh: node: Permission denied') + ) + const failure = await ensurePinnedRelayRuntime(context, false).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(PinnedRelayFallbackError) + expect(failure).toMatchObject({ reason: 'noexec' }) + + vi.mocked(execCommand).mockResolvedValueOnce('ldd (GNU libc) 2.31') + await expect( + planPinnedNodeRelay({ conn, host, baseVersion: '0.1.0+abc', targetId: 'target-1' }) + ).resolves.toEqual({ kind: 'host-node', fallbackReason: 'noexec', remembered: true }) + }) + + it('keeps an unclassified runtime failure as an error, not a step down', async () => { + const failure = new RemoteNodeRuntimeSelfTestError(1, 'something unexpected') + vi.mocked(ensureRemoteOrcadNodeRuntime).mockRejectedValueOnce(failure) + await expect(ensurePinnedRelayRuntime(context, false)).rejects.toBe(failure) + }) +}) + +describe('verifyPinnedRelayInstall', () => { + beforeEach(() => { + vi.mocked(execCommand).mockResolvedValue(REMOTE_NODE_RUNTIME_READY) + }) + + it('confirms the runtime under the store lock once the relay ref is visible', async () => { + vi.mocked(runPinnedRuntimeSelfTest).mockResolvedValueOnce({ verdict: 'failed', detail: 'x' }) + await verifyPinnedRelayInstall(context).catch(() => {}) + expect(withRuntimeStoreLock).toHaveBeenCalledWith( + conn, + host, + '/home/u/.orca-remote/runtimes', + expect.any(Function), + undefined + ) + expect(ensureRemoteOrcadNodeRuntime).not.toHaveBeenCalled() + }) + + it('reinstalls a runtime a store GC collected before the ref existed', async () => { + vi.mocked(execCommand).mockResolvedValueOnce(REMOTE_NODE_RUNTIME_MISSING) + vi.mocked(runPinnedRuntimeSelfTest).mockResolvedValueOnce({ verdict: 'failed', detail: 'x' }) + await verifyPinnedRelayInstall(context).catch(() => {}) + expect(ensureRemoteOrcadNodeRuntime).toHaveBeenCalledOnce() + }) + + it('passes a verified runtime through', async () => { + vi.mocked(runPinnedRuntimeSelfTest).mockResolvedValueOnce({ + verdict: 'passed', + report: { + ok: true, + nonce: 'n', + node: 'v24.21.0', + napi: '10', + glibcVersionRuntime: '2.31', + runtime: 'pinned-node' + } + }) + await expect(verifyPinnedRelayInstall(context)).resolves.toBeUndefined() + expect(runPinnedRuntimeSelfTest).toHaveBeenCalledWith( + conn, + context.remoteRelayDir, + expect.stringMatching(/\/\.orca-remote\/runtimes\/node-[0-9a-f]{64}\/bin\/node$/), + undefined, + { host, expectPinnedVersion: true } + ) + }) + + it('self-tests rung C on the host Node without the pinned version check or a cached refusal', async () => { + vi.mocked(withRuntimeStoreLock).mockClear() + vi.mocked(execCommand).mockResolvedValue('') + const run = new RelayRuntimeLadderRun('target-1', null) + const hostPlan: HostNodeAddonRelayPlan = { + kind: 'host-node-addons', + target: 'linux-x64-glibc', + glibc: { major: 2, minor: 31 }, + fullVersion: '0.1.0+0123456789ab', + addons: { dir: '/tmp/a', digest: 'd', dispose: async () => {} }, + nodePath: '/opt/node18/bin/node', + hostNode: { version: { major: 18, minor: 20 }, napi: 9 } + } + vi.mocked(runPinnedRuntimeSelfTest).mockResolvedValueOnce({ + verdict: 'refused', + refusal: 'libc_floor', + detail: "GLIBC_2.33' not found" + }) + await expect( + verifyPinnedRelayInstall({ ...context, plan: hostPlan, run }) + ).rejects.toMatchObject({ reason: 'libc_floor' }) + expect(runPinnedRuntimeSelfTest).toHaveBeenCalledWith( + conn, + context.remoteRelayDir, + '/opt/node18/bin/node', + undefined, + { host, expectPinnedVersion: false } + ) + expect(run.selfTest).toBe('refused') + // Rung C runs no managed runtime, so there is nothing to hold under the store lock. + expect(withRuntimeStoreLock).not.toHaveBeenCalled() + // A host Node refusal says nothing about Orca's pinned Node on this host. + vi.mocked(execCommand).mockResolvedValueOnce('ldd (GNU libc) 2.31') + const next = await planPinnedNodeRelay({ + conn, + host, + baseVersion: '0.1.0+abcdef012345', + targetId: 'target-1', + materializeOrcad: () => Promise.reject(new Error('stop here')) + }) + expect(next).toMatchObject({ fallbackReason: 'artifacts_unavailable' }) + }) + + it('falls back on a refusal', async () => { + vi.mocked(runPinnedRuntimeSelfTest).mockResolvedValueOnce({ + verdict: 'refused', + refusal: 'libc_floor', + detail: "GLIBC_2.33' not found" + }) + await expect(verifyPinnedRelayInstall(context)).rejects.toMatchObject({ + reason: 'libc_floor' + }) + }) + + it('never falls back on an unverifiable self-test', async () => { + vi.mocked(runPinnedRuntimeSelfTest).mockResolvedValueOnce({ + verdict: 'unverifiable', + detail: 'timed out' + }) + const failure = await verifyPinnedRelayInstall(context).catch((error: unknown) => error) + expect(failure).not.toBeInstanceOf(PinnedRelayFallbackError) + expect(String(failure)).toContain('unverifiable') + }) + + it('rethrows an unconfirmed teardown so the install lock stays held', async () => { + const lost = Object.assign(new Error('lost'), { sshChannelCloseConfirmed: false }) + vi.mocked(runPinnedRuntimeSelfTest).mockResolvedValueOnce({ + verdict: 'unverifiable', + detail: 'lost', + cause: lost + }) + await expect(verifyPinnedRelayInstall(context)).rejects.toBe(lost) + }) + + it('makes the macOS spawn-helper executable before the self-test', async () => { + vi.mocked(execCommand).mockResolvedValue('') + vi.mocked(runPinnedRuntimeSelfTest).mockResolvedValueOnce({ verdict: 'failed', detail: 'x' }) + await verifyPinnedRelayInstall({ + ...context, + host: getRemoteHostPlatform('darwin-arm64'), + plan: { ...plan, target: 'darwin-arm64' } + }).catch(() => {}) + expect(execCommand).toHaveBeenCalledWith( + conn, + expect.stringContaining("node_modules/node-pty/build/Release/spawn-helper'"), + expect.anything() + ) + }) +}) + +describe('pinned relay on a Windows host', () => { + const windowsHost = getRemoteHostPlatform('win32-x64') + const windowsContext = { + ...context, + host: windowsHost, + remoteRelayDir: 'C:/Users/u/.orca-remote/relay-0.1.0+feedfacecafe', + plan: { ...plan, target: 'win32-x64' as const, glibc: null } + } + + it('checks the warm runtime through one unwrapped powershell.exe', async () => { + vi.mocked(execCommand).mockResolvedValueOnce(`${REMOTE_NODE_RUNTIME_READY}\r\n`) + await ensurePinnedRelayRuntime(windowsContext, true) + const [, command, options] = vi.mocked(execCommand).mock.calls[0] + expect(command).toMatch(/^powershell\.exe /) + expect(options).toMatchObject({ wrapCommand: false }) + expect(decodeRemotePowerShellScript(command)).toContain('/.orca-remote/runtimes/node-') + expect(ensureRemoteOrcadNodeRuntime).not.toHaveBeenCalled() + }) + + it('steps down to host Node, and remembers it, when security software touched node.exe', async () => { + vi.mocked(ensureRemoteOrcadNodeRuntime).mockRejectedValueOnce( + new RemoteNodeRuntimeSecurityModifiedError('node.exe changed after it ran') + ) + const failure = await ensurePinnedRelayRuntime(windowsContext, false).catch( + (error: unknown) => error + ) + expect(failure).toBeInstanceOf(PinnedRelayFallbackError) + expect(failure).toMatchObject({ reason: 'security_software' }) + await expect( + planPinnedNodeRelay({ + conn, + host: windowsHost, + baseVersion: '0.1.0+abc', + targetId: 'target-1' + }) + ).resolves.toEqual({ kind: 'host-node', fallbackReason: 'security_software', remembered: true }) + }) + + it('classifies application control blocking node.exe as a refusal', async () => { + vi.mocked(ensureRemoteOrcadNodeRuntime).mockRejectedValueOnce( + new RemoteNodeRuntimeSelfTestError(-1, 'This program is blocked by group policy.') + ) + await expect(ensurePinnedRelayRuntime(windowsContext, false)).rejects.toMatchObject({ + reason: 'noexec' + }) + }) + + it('self-tests on node.exe with no chmod step', async () => { + vi.mocked(runPinnedRuntimeSelfTest).mockResolvedValueOnce({ + verdict: 'passed', + report: { + ok: true, + nonce: 'n', + node: 'v24.21.0', + napi: '10', + glibcVersionRuntime: null, + runtime: 'pinned-node' + } + }) + await expect(verifyPinnedRelayInstall(windowsContext)).resolves.toBeUndefined() + expect(execCommand).not.toHaveBeenCalled() + expect(runPinnedRuntimeSelfTest).toHaveBeenCalledWith( + conn, + windowsContext.remoteRelayDir, + expect.stringMatching( + /^C:\/Users\/u\/\.orca-remote\/runtimes\/node-[0-9a-f]{64}\/node\.exe$/ + ), + undefined, + { host: windowsHost, expectPinnedVersion: true } + ) + }) +}) diff --git a/src/main/ssh/ssh-relay-pinned-node-install.ts b/src/main/ssh/ssh-relay-pinned-node-install.ts new file mode 100644 index 000000000000..10016458da65 --- /dev/null +++ b/src/main/ssh/ssh-relay-pinned-node-install.ts @@ -0,0 +1,190 @@ +/** Host-side steps of the pinned-Node relay install: the runtime store and the pre-launch self-test. */ +import { orcadNodePtyNativeArtifacts } from '../../shared/orcad-artifacts' +import { + ensureRemoteOrcadNodeRuntime, + remoteNodeRuntimeDir, + remoteNodeRuntimePresentCommand, + RemoteNodeRuntimeSecurityModifiedError, + RemoteNodeRuntimeSelfTestError, + REMOTE_NODE_RUNTIME_READY +} from './orcad-remote-node-runtime' +import { withRuntimeStoreLock } from './remote-node-runtime-store-lock' +import type { SshConnection } from './ssh-connection' +import { shellEscape } from './ssh-connection-utils' +import { execCommand } from './ssh-relay-deploy-helpers' +import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' +import { + isPinnedRuntimeRefusal, + PinnedRelayFallbackError, + pinnedRelayNodePath, + recordPinnedRuntimeRefusal, + type PinnedRelayPlan +} from './ssh-relay-pinned-node' +import type { PrebuiltRelayPlan } from './ssh-relay-host-node-addons' +import type { RelayRuntimeLadderRun } from './ssh-relay-runtime-resolution' +import { + classifyPinnedRuntimeFailure, + runPinnedRuntimeSelfTest +} from './ssh-relay-runtime-self-test' +import { + isWindowsRemoteHost, + joinRemotePath, + remoteDirname, + type RemoteHostPlatform +} from './ssh-remote-platform' + +type PinnedInstallContext = { + conn: SshConnection + host: RemoteHostPlatform + remoteRelayDir: string + plan: PrebuiltRelayPlan + targetId: string + signal?: AbortSignal + run?: RelayRuntimeLadderRun +} + +function refuse(context: PinnedInstallContext, error: PinnedRelayFallbackError): never { + // Only Orca's pinned Node is cached as refused; a host Node refusal names nothing reusable. + if (context.plan.kind === 'pinned-node' && isPinnedRuntimeRefusal(error.reason)) { + recordPinnedRuntimeRefusal(context.targetId, context.plan.target, error.reason) + } + throw error +} + +export function prebuiltRelayNodePath(context: { + host: RemoteHostPlatform + remoteRelayDir: string + plan: PrebuiltRelayPlan +}): string { + return context.plan.kind === 'pinned-node' + ? pinnedRelayNodePath(context.host, context.remoteRelayDir, context.plan.target) + : context.plan.nodePath +} + +/** + * The warm path only checks the verified marker; hashing ~120 MiB on every reconnect would + * spend the warm-reconnect budget (design D10 G2.6). A cold install verifies in full. + */ +export async function ensurePinnedRelayRuntime( + context: PinnedInstallContext & { plan: PinnedRelayPlan }, + relayAlreadyInstalled: boolean +): Promise { + const { conn, host, remoteRelayDir, plan, signal, run } = context + if (relayAlreadyInstalled) { + const runtimeDir = remoteNodeRuntimeDir(host, remoteRelayDir, plan.target) + const present = await execCommand(conn, remoteNodeRuntimePresentCommand(host, runtimeDir), { + signal, + wrapCommand: !isWindowsRemoteHost(host) + }) + if (present.trim() === REMOTE_NODE_RUNTIME_READY) { + if (run && run.runtimeTransfer === 'none') { + run.runtimeTransfer = 'cached' + } + return + } + } + try { + const { transfer } = await ensureRemoteOrcadNodeRuntime({ + conn, + host, + slotDir: remoteRelayDir, + target: plan.target, + archivePath: plan.runtimeArchive, + signal + }) + if (run && run.runtimeTransfer !== 'uploaded') { + run.runtimeTransfer = transfer + } + } catch (error) { + if (error instanceof PinnedRelayFallbackError) { + refuse(context, error) + } + if (error instanceof RemoteNodeRuntimeSecurityModifiedError) { + refuse(context, new PinnedRelayFallbackError('security_software', error.detail)) + } + if (error instanceof RemoteNodeRuntimeSelfTestError) { + const refusal = classifyPinnedRuntimeFailure(error.exitStatus, error.output) + if (refusal) { + refuse(context, new PinnedRelayFallbackError(refusal, error.message)) + } + } + throw error + } +} + +/** + * The runtime was ensured before the relay dir carried its ref, so a store GC in between could + * have collected it. Now that the ref is visible, a check under the store lock is final: GC only + * deletes while holding that lock, and it never deletes a referenced runtime. + */ +async function confirmPinnedRuntimeHeld( + context: PinnedInstallContext & { plan: PinnedRelayPlan } +): Promise { + const { conn, host, remoteRelayDir, plan, signal } = context + const runtimeDir = remoteNodeRuntimeDir(host, remoteRelayDir, plan.target) + const present = await withRuntimeStoreLock( + conn, + host, + remoteDirname(runtimeDir, host), + () => execCommand(conn, remoteNodeRuntimePresentCommand(host, runtimeDir), { signal }), + signal + ) + if (present.trim() !== REMOTE_NODE_RUNTIME_READY) { + console.warn( + `[ssh-relay] Pinned Node runtime vanished before launch; reinstalling ${runtimeDir}` + ) + await ensurePinnedRelayRuntime(context, false) + } +} + +/** Runs after the payload is promoted and before `.install-complete`, so a refused dir never completes. */ +export async function verifyPinnedRelayInstall(context: PinnedInstallContext): Promise { + const { conn, host, remoteRelayDir, plan, signal } = context + // Why POSIX only: the store lock and store GC are POSIX-only for now; rung C has no managed runtime. + if (plan.kind === 'pinned-node' && !isWindowsRemoteHost(host)) { + await confirmPinnedRuntimeHeld({ ...context, plan }) + } + const spawnHelpers = orcadNodePtyNativeArtifacts(plan.target).filter((artifact) => + artifact.endsWith('/spawn-helper') + ) + if (spawnHelpers.length > 0) { + // SFTP drops executable modes; node-pty posix_spawns this helper on macOS. + await execCommand( + conn, + `chmod 755 ${spawnHelpers + .map((artifact) => + shellEscape(joinRemotePath(host, remoteRelayDir, ...artifact.split('/'))) + ) + .join(' ')}`, + { signal } + ) + } + const nodePath = prebuiltRelayNodePath(context) + const verdict = await runPinnedRuntimeSelfTest(conn, remoteRelayDir, nodePath, signal, { + host, + expectPinnedVersion: plan.kind === 'pinned-node' + }) + if (context.run && verdict.verdict !== 'unverifiable') { + context.run.selfTest = verdict.verdict + } + switch (verdict.verdict) { + case 'passed': + console.log( + `[ssh-relay] ${plan.kind === 'pinned-node' ? 'Pinned' : 'Host'} Node self-test passed at ${remoteRelayDir} (${verdict.report.node}, glibc ${verdict.report.glibcVersionRuntime ?? 'n/a'})` + ) + return + case 'refused': + refuse(context, new PinnedRelayFallbackError(verdict.refusal, verdict.detail)) + break + case 'unverifiable': + // Why rethrow the cause: an unconfirmed teardown must keep the install lock held. + if (isUnconfirmedSshCommandTermination(verdict.cause)) { + throw verdict.cause + } + throw new Error( + `The relay runtime self-test at ${remoteRelayDir} is unverifiable; retrying on the next connect: ${verdict.detail}` + ) + case 'failed': + throw new Error(`The relay runtime self-test at ${remoteRelayDir} failed: ${verdict.detail}`) + } +} diff --git a/src/main/ssh/ssh-relay-pinned-node.integration.test.ts b/src/main/ssh/ssh-relay-pinned-node.integration.test.ts new file mode 100644 index 000000000000..d1581f0fc781 --- /dev/null +++ b/src/main/ssh/ssh-relay-pinned-node.integration.test.ts @@ -0,0 +1,165 @@ +/** + * The pinned-Node relay install as a host sees it, with this machine as the host: the runtime + * promoted from the official archive, relay + slot addons in a runtime-folded version dir, the + * self-test, then a detached daemon on the pinned Node answering a `--connect` handshake. + * + * Needs build outputs, so it skips unless all are present: the pinned archive + * (ORCA_NODE_RUNTIME_CACHE_DIR or out/node-runtime-cache), `pnpm build:relay`, and an orcad + * build for this host (ORCA_E2E_ORCAD_DIR or out/orcad). + */ +import { + copyFileSync, + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { runProcessSync } from '../../shared/child-process/run-process' +import { + NODE_RUNTIME_ASSETS, + NODE_RUNTIME_PIN, + type ServerTarget +} from '../../shared/node-runtime-pin' +import { + promoteRemoteNodeRuntimeCommand, + remoteNodeRuntimeDir, + REMOTE_NODE_RUNTIME_READY +} from './orcad-remote-node-runtime' +import { + pinnedNodeRelayFullVersion, + pinnedRelayNodePath, + stagePinnedRelayAddons +} from './ssh-relay-pinned-node' +import { + evaluatePinnedRuntimeVersion, + evaluateRelayRuntimeSelfTest, + pinnedRuntimeVersionCommand, + relayRuntimeSelfTestCommand +} from './ssh-relay-runtime-self-test' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +function hostTarget(): ServerTarget | null { + if (process.platform === 'darwin') { + return process.arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64' + } + return null +} + +const target = hostTarget() +const relayPlatform = `${process.platform}-${process.arch}` +const archive = target + ? [process.env.ORCA_NODE_RUNTIME_CACHE_DIR, resolve('out/node-runtime-cache')] + .filter((dir): dir is string => !!dir) + .map((dir) => join(dir, NODE_RUNTIME_ASSETS[target].archive)) + .find((path) => existsSync(path)) + : undefined +const localRelayDir = resolve('out/relay', relayPlatform) +const orcadDir = process.env.ORCA_E2E_ORCAD_DIR ?? resolve('out/orcad') +const ready = + !!target && + !!archive && + existsSync(join(localRelayDir, 'relay.js')) && + existsSync(join(orcadDir, 'node_modules/node-pty/build/Release/pty.node')) + +function sh( + command: string, + timeoutMs = 60_000 +): { stdout: string; stderr: string; code: number | null } { + const result = runProcessSync({ program: '/bin/sh', args: ['-c', command], timeoutMs }) + return { stdout: result.stdout, stderr: result.stderr, code: result.code } +} + +const directories: string[] = [] +afterAll(() => { + for (const dir of directories) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe.skipIf(!ready)('pinned-Node relay on a local POSIX host', () => { + it('installs, self-tests and serves a handshake from the pinned runtime', async () => { + const host = getRemoteHostPlatform( + relayPlatform === 'darwin-arm64' ? 'darwin-arm64' : 'darwin-x64' + ) + const home = mkdtempSync(join(tmpdir(), 'orca-pin-')) + directories.push(home) + const addons = await stagePinnedRelayAddons(orcadDir, target!) + const baseVersion = sh(`cat ${JSON.stringify(join(localRelayDir, '.version'))}`).stdout.trim() + const fullVersion = pinnedNodeRelayFullVersion( + baseVersion, + NODE_RUNTIME_ASSETS[target!].executableSha256, + addons.digest + ) + const relayDir = join(home, '.orca-remote', `relay-${fullVersion}`) + cpSync(localRelayDir, relayDir, { recursive: true }) + cpSync(addons.dir, relayDir, { recursive: true }) + await addons.dispose() + writeFileSync(join(relayDir, '.version'), fullVersion) + + const runtimeDir = remoteNodeRuntimeDir(host, relayDir, target!) + const stageDir = join(home, '.orca-remote', 'runtimes', '.stage-e2e') + mkdirSync(stageDir, { recursive: true }) + copyFileSync(archive!, join(stageDir, NODE_RUNTIME_ASSETS[target!].archive)) + const promoted = sh( + promoteRemoteNodeRuntimeCommand(host, { + stageDir, + archive: NODE_RUNTIME_ASSETS[target!].archive, + runtimeDir, + target: target!, + token: 'e2e' + }) + ) + expect(promoted.stdout.trim().split('\n').at(-1)).toBe(REMOTE_NODE_RUNTIME_READY) + + const nodePath = pinnedRelayNodePath(host, relayDir, target!) + expect( + evaluatePinnedRuntimeVersion(sh(pinnedRuntimeVersionCommand(nodePath)).stdout) + ).toBeNull() + const selfTest = evaluateRelayRuntimeSelfTest( + sh(relayRuntimeSelfTestCommand(relayDir, nodePath, 'e2e-nonce')).stdout, + 'e2e-nonce' + ) + expect(selfTest).toMatchObject({ + verdict: 'passed', + report: { runtime: 'pinned-node', node: `v${NODE_RUNTIME_PIN.version}` } + }) + + // Short socket dir: a temp $HOME can exceed sun_path. + const sockDir = mkdtempSync('/tmp/orca-pin-sock-') + directories.push(sockDir) + const sock = join(sockDir, 'relay.sock') + const credential = join(relayDir, 'relay.credential') + const q = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'` + const launched = sh( + `cd ${q(relayDir)} && nohup ${q(nodePath)} relay.js --detached --grace-time 60 ` + + `--sock-path ${q(sock)} --credential-file ${q(credential)} > relay.log 2>&1 ({ execCommand: vi.fn() })) + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: All connection operations are mocked. +const conn = {} as SshConnection +const SHA = NODE_RUNTIME_ASSETS['linux-x64-glibc'].executableSha256 +const directories: string[] = [] + +function tempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)) + directories.push(dir) + return dir +} + +function fakeOrcadSlot(target: Parameters[0]): string { + const dir = tempDir('orcad-slot-') + for (const file of pinnedRelayAddonFiles(target)) { + const path = join(dir, ...file.split('/')) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, `bytes of ${file}`) + } + return dir +} + +beforeEach(() => { + vi.mocked(execCommand).mockReset() + resetPinnedRuntimeRefusalsForTests() + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) +afterEach(() => { + vi.restoreAllMocks() + for (const dir of directories.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('remote runtime setting', () => { + it('defaults to legacy, lets the host setting win, and accepts only known env values', () => { + expect(resolveSshRemoteRuntime(undefined, {})).toBe('legacy') + expect(resolveSshRemoteRuntime({}, { [SSH_REMOTE_RUNTIME_ENV]: 'pinned-node' })).toBe( + 'pinned-node' + ) + expect(resolveSshRemoteRuntime({}, { [SSH_REMOTE_RUNTIME_ENV]: 'bun' })).toBe('legacy') + expect( + resolveSshRemoteRuntime( + { remoteRuntime: 'legacy' }, + { [SSH_REMOTE_RUNTIME_ENV]: 'pinned-node' } + ) + ).toBe('legacy') + }) +}) + +describe('pinned relay version (D8.1)', () => { + const base = '0.1.0+abcdef012345' + + it('differs from the host-Node build and stays a valid relay version dir', () => { + const pinned = pinnedNodeRelayFullVersion(base, SHA, 'addons') + expect(pinned).not.toBe(base) + expect(pinned).toMatch(/^0\.1\.0\+[0-9a-f]{12}$/) + expect(remoteInstallVersionDirRegex(RELAY_INSTALL_MODEL).test(`relay-${pinned}`)).toBe(true) + }) + + it('is stable for the same inputs and changes with the runtime or the addons', () => { + const pinned = pinnedNodeRelayFullVersion(base, SHA, 'addons') + expect(pinnedNodeRelayFullVersion(base, SHA, 'addons')).toBe(pinned) + expect(pinnedNodeRelayFullVersion(base, 'f'.repeat(64), 'addons')).not.toBe(pinned) + expect(pinnedNodeRelayFullVersion(base, SHA, 'other')).not.toBe(pinned) + expect(pinnedNodeRelayFullVersion('0.1.0+000000000000', SHA, 'addons')).not.toBe(pinned) + }) + + it('refuses a malformed base version rather than inventing a dir name', () => { + expect(() => pinnedNodeRelayFullVersion('../x', SHA, 'a')).toThrow('semver') + }) +}) + +describe('staged addons', () => { + it('copies the slot addons with a runtime ref, and digests their bytes', async () => { + const slot = fakeOrcadSlot('darwin-arm64') + const addons = await stagePinnedRelayAddons(slot, 'darwin-arm64', tempDir('stage-')) + const sha = NODE_RUNTIME_ASSETS['darwin-arm64'].executableSha256 + expect(readFileSync(join(addons.dir, `${RELAY_RUNTIME_REF_PREFIX}${sha}`), 'utf8').trim()).toBe( + sha + ) + expect(existsSync(join(addons.dir, 'node_modules/node-pty/build/Release/pty.node'))).toBe(true) + expect(existsSync(join(addons.dir, 'node_modules/node-pty/build/Release/spawn-helper'))).toBe( + true + ) + expect(existsSync(join(addons.dir, 'node_modules/@parcel/watcher/watcher.node'))).toBe(true) + + writeFileSync(join(slot, 'node_modules/node-pty/build/Release/pty.node'), 'rebuilt') + const changed = await stagePinnedRelayAddons(slot, 'darwin-arm64', tempDir('stage-')) + expect(changed.digest).not.toBe(addons.digest) + await addons.dispose() + expect(existsSync(addons.dir)).toBe(false) + }) + + it('ships the win32 ConPTY pair and the process-table addon at the names the relay loads', async () => { + const files = pinnedRelayAddonFiles('win32-x64') + expect(files).toEqual( + expect.arrayContaining([ + 'node_modules/node-pty/build/Release/conpty.node', + 'node_modules/node-pty/build/Release/conpty_console_list.node', + 'node_modules/node-pty/build/Release/conpty/conpty.dll', + 'node_modules/node-pty/build/Release/conpty/OpenConsole.exe', + RELAY_WINDOWS_PROCESS_TREE_FILENAME + ]) + ) + expect(files).not.toContain('node_modules/node-pty/build/Release/pty.node') + expect(pinnedRelayAddonFiles('linux-x64-glibc')).not.toContain( + RELAY_WINDOWS_PROCESS_TREE_FILENAME + ) + const addons = await stagePinnedRelayAddons( + fakeOrcadSlot('win32-x64'), + 'win32-x64', + tempDir('stage-') + ) + expect(existsSync(join(addons.dir, RELAY_WINDOWS_PROCESS_TREE_FILENAME))).toBe(true) + await addons.dispose() + }) + + it('refuses a slot missing an addon and leaves nothing behind', async () => { + const slot = fakeOrcadSlot('linux-x64-glibc') + rmSync(join(slot, 'node_modules/@parcel/watcher/watcher.node')) + const parent = tempDir('stage-') + await expect(stagePinnedRelayAddons(slot, 'linux-x64-glibc', parent)).rejects.toThrow( + 'watcher.node' + ) + expect(readdirSync(parent)).toEqual([]) + }) +}) + +describe('pinned runtime layout', () => { + it('launches from the shared runtimes store beside the version dirs', () => { + const host = getRemoteHostPlatform('linux-x64') + expect( + pinnedRelayNodePath(host, '/home/u/.orca-remote/relay-0.1.0+abc', 'linux-x64-glibc') + ).toBe(`/home/u/.orca-remote/runtimes/node-${SHA}/bin/node`) + }) + + it('keeps node.exe under its real name at the runtime root on Windows', () => { + const host = getRemoteHostPlatform('win32-x64') + const sha = NODE_RUNTIME_ASSETS['win32-x64'].executableSha256 + const nodePath = pinnedRelayNodePath( + host, + 'C:/Users/u/.orca-remote/relay-0.1.0+abc', + 'win32-x64' + ) + expect(nodePath).toBe(`C:/Users/u/.orca-remote/runtimes/node-${sha}/node.exe`) + expect(path.win32.normalize(nodePath)).toBe( + `C:\\Users\\u\\.orca-remote\\runtimes\\node-${sha}\\node.exe` + ) + expect(path.win32.basename(nodePath)).toBe('node.exe') + }) +}) + +describe('planPinnedNodeRelay', () => { + const base = '0.1.0+abcdef012345' + + it('plans a Windows host on the win32 slot without a libc probe', async () => { + const plan = await planPinnedNodeRelay({ + conn, + host: getRemoteHostPlatform('win32-arm64'), + baseVersion: base, + targetId: 't', + materializeOrcad: async (target) => fakeOrcadSlot(target) + }) + expect(execCommand).not.toHaveBeenCalled() + expect(plan).toMatchObject({ kind: 'pinned-node', target: 'win32-arm64', glibc: null }) + if (plan.kind === 'pinned-node') { + expect(plan.fullVersion).toBe( + pinnedNodeRelayFullVersion( + base, + NODE_RUNTIME_ASSETS['win32-arm64'].executableSha256, + plan.addons.digest + ) + ) + await plan.addons.dispose() + } + }) + + it('falls back on Windows when this client packaged no win32 slot', async () => { + await expect( + planPinnedNodeRelay({ + conn, + host: getRemoteHostPlatform('win32-x64'), + baseVersion: base, + targetId: 't', + materializeOrcad: () => Promise.reject(new Error('template has no win32-x64')) + }) + ).resolves.toEqual({ kind: 'host-node', fallbackReason: 'artifacts_unavailable' }) + }) + + it('refuses a glibc below the pinned Node floor without uploading anything', async () => { + vi.mocked(execCommand).mockResolvedValueOnce('ldd (GNU libc) 2.17') + const materializeOrcad = vi.fn() + await expect( + planPinnedNodeRelay({ + conn, + host: getRemoteHostPlatform('linux-x64'), + baseVersion: base, + targetId: 't', + materializeOrcad + }) + ).resolves.toEqual({ kind: 'host-node', fallbackReason: 'libc_floor' }) + expect(materializeOrcad).not.toHaveBeenCalled() + }) + + it('falls back when the host C library cannot be identified', async () => { + vi.mocked(execCommand).mockResolvedValue('') + await expect( + planPinnedNodeRelay({ + conn, + host: getRemoteHostPlatform('linux-x64'), + baseVersion: base, + targetId: 't' + }) + ).resolves.toEqual({ kind: 'host-node', fallbackReason: 'target_unresolved' }) + }) + + it('does not descend when the libc probe itself is lost', async () => { + vi.mocked(execCommand).mockRejectedValue(new Error('channel closed')) + await expect( + planPinnedNodeRelay({ + conn, + host: getRemoteHostPlatform('linux-x64'), + baseVersion: base, + targetId: 't' + }) + ).rejects.toThrow('channel closed') + }) + + it('falls back when this client has no orcad slot for the target', async () => { + vi.mocked(execCommand).mockResolvedValueOnce('ldd (GNU libc) 2.31') + await expect( + planPinnedNodeRelay({ + conn, + host: getRemoteHostPlatform('linux-x64'), + baseVersion: base, + targetId: 't', + materializeOrcad: () => Promise.reject(new Error('template missing')) + }) + ).resolves.toEqual({ kind: 'host-node', fallbackReason: 'artifacts_unavailable' }) + }) + + it('remembers a host refusal for the session', async () => { + recordPinnedRuntimeRefusal('t', 'linux-x64-glibc', 'noexec') + vi.mocked(execCommand).mockResolvedValue('ldd (GNU libc) 2.31') + const materializeOrcad = vi.fn() + await expect( + planPinnedNodeRelay({ + conn, + host: getRemoteHostPlatform('linux-x64'), + baseVersion: base, + targetId: 't', + materializeOrcad + }) + ).resolves.toEqual({ kind: 'host-node', fallbackReason: 'noexec', remembered: true }) + expect(materializeOrcad).not.toHaveBeenCalled() + }) + + it('plans a pinned relay with a runtime-folded version for a supported host', async () => { + vi.mocked(execCommand).mockResolvedValueOnce('ldd (GNU libc) 2.28') + const plan = await planPinnedNodeRelay({ + conn, + host: getRemoteHostPlatform('linux-x64'), + baseVersion: base, + targetId: 't', + materializeOrcad: async (target) => fakeOrcadSlot(target) + }) + expect(plan).toMatchObject({ + kind: 'pinned-node', + target: 'linux-x64-glibc', + glibc: { major: 2, minor: 28 } + }) + if (plan.kind === 'pinned-node') { + expect(plan.fullVersion).toBe(pinnedNodeRelayFullVersion(base, SHA, plan.addons.digest)) + await plan.addons.dispose() + } + }) +}) diff --git a/src/main/ssh/ssh-relay-pinned-node.ts b/src/main/ssh/ssh-relay-pinned-node.ts new file mode 100644 index 000000000000..602f11222c34 --- /dev/null +++ b/src/main/ssh/ssh-relay-pinned-node.ts @@ -0,0 +1,338 @@ +/** + * Plan a relay launch on Orca's pinned Node with the orcad slot's prebuilt addons, instead + * of the host's Node plus a host-side npm install (design D5, D6 rung A, D8.1). + * + * Opt-in per host (`SshTarget.remoteRuntime`). Anything this module cannot + * establish on the client, and every classified refusal from the host, falls back to the + * legacy host-Node path with a logged reason. + */ +import { createHash } from 'node:crypto' +import { chmod, copyFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { getAppEnvironment } from '../../shared/app-environment' +import { + isWindowsServerTarget, + NODE_RUNTIME_ASSETS, + type ServerTarget +} from '../../shared/node-runtime-pin' +import { + ORCAD_NODE_PTY_JS_ARTIFACTS, + ORCAD_PARCEL_WATCHER_ENTRY, + ORCAD_PARCEL_WATCHER_NATIVE, + ORCAD_WINDOWS_PROCESS_TREE_FILENAME, + orcadNodePtyNativeArtifacts, + orcadNodeRuntimeExecutable +} from '../../shared/orcad-artifacts' +import { + DEFAULT_SSH_REMOTE_RUNTIME, + SSH_REMOTE_RUNTIMES, + type SshRemoteRuntime, + type SshTarget +} from '../../shared/ssh-types' +import { materializeOrcadArtifact } from './orcad-artifact-materializer' +import { + resolveOrcadDeploymentTargetFacts, + UnidentifiedHostLibcError, + type GlibcVersion, + type OrcadDeploymentTargetFacts +} from './orcad-deployment-target' +import { remoteNodeRuntimeDir } from './orcad-remote-node-runtime' +import { fileSha256, materializeNodeRuntimeArchive } from './pinned-runtime-materializer' +import type { SshConnection } from './ssh-connection' +import { PINNED_RUNTIME_REFUSALS, type PinnedRuntimeRefusal } from './ssh-relay-runtime-self-test' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' + +/** Content-keyed like ripgrep's refs, so runtime GC can find holders without a new concept (D5). */ +export const RELAY_RUNTIME_REF_PREFIX = '.runtime-ref-node-' +/** Official linux builds of the pinned Node need glibc 2.28; older hosts are rung B/C territory. */ +export const PINNED_NODE_GLIBC_FLOOR: GlibcVersion = { major: 2, minor: 28 } +/** Developer override when no per-host setting is saved. */ +export const SSH_REMOTE_RUNTIME_ENV = 'ORCA_SSH_REMOTE_RUNTIME' + +export function resolveSshRemoteRuntime( + target: Pick | undefined, + env: NodeJS.ProcessEnv = process.env +): SshRemoteRuntime { + if (target?.remoteRuntime) { + return target.remoteRuntime + } + const fromEnv = SSH_REMOTE_RUNTIMES.find((runtime) => runtime === env[SSH_REMOTE_RUNTIME_ENV]) + return fromEnv ?? DEFAULT_SSH_REMOTE_RUNTIME +} + +export function isGlibcBelow(version: GlibcVersion, floor: GlibcVersion): boolean { + return ( + version.major < floor.major || (version.major === floor.major && version.minor < floor.minor) + ) +} + +/** + * Folds the runtime and addon bytes into the relay's content version, so a pinned build and a + * host-Node build of the same relay never share a directory or a socket (D8.1). + */ +export function pinnedNodeRelayFullVersion( + baseVersion: string, + executableSha256: string, + addonDigest: string +): string { + const match = /^(v?[0-9]+\.[0-9]+\.[0-9]+)(?:\+[0-9a-f]+)?$/.exec(baseVersion) + if (!match) { + throw new Error(`Relay version is not [+]: ${baseVersion}`) + } + const digest = createHash('sha256') + .update(`${baseVersion}\0node-runtime\0${executableSha256}\0${addonDigest}`) + .digest('hex') + return `${match[1]}+${digest.slice(0, 12)}` +} + +export function pinnedRelayAddonFiles(target: ServerTarget): string[] { + return [ + ...ORCAD_NODE_PTY_JS_ARTIFACTS, + ...orcadNodePtyNativeArtifacts(target), + ORCAD_PARCEL_WATCHER_ENTRY, + ORCAD_PARCEL_WATCHER_NATIVE, + // The slot's patched copy, so the process table never falls back to a PowerShell scan. + ...(isWindowsServerTarget(target) ? [ORCAD_WINDOWS_PROCESS_TREE_FILENAME] : []) + ] +} + +export type PinnedRelayAddons = { + /** Upload this directory's contents into the relay version dir. */ + dir: string + digest: string + dispose: () => Promise +} + +/** Copies this target's node-pty and watcher out of a verified orcad slot, beside a runtime ref. */ +export async function stagePinnedRelayAddons( + orcadDir: string, + target: ServerTarget, + stagingParent: string = tmpdir(), + /** Rung C runs on the host's Node, so its dir must not hold a pinned runtime against GC. */ + options: { runtimeRef: boolean } = { runtimeRef: true } +): Promise { + const dir = await mkdtemp(join(stagingParent, 'orca-relay-addons-')) + try { + const hash = createHash('sha256') + for (const file of pinnedRelayAddonFiles(target).sort()) { + const source = join(orcadDir, ...file.split('/')) + const sha = await fileSha256(source) + if (!sha) { + throw new Error(`The orcad slot for ${target} has no ${file}`) + } + const destination = join(dir, ...file.split('/')) + await mkdir(dirname(destination), { recursive: true }) + await copyFile(source, destination) + if (file.endsWith('/spawn-helper')) { + await chmod(destination, 0o755) + } + hash.update(`${file}\0${sha}\n`) + } + if (options.runtimeRef) { + const executableSha256 = NODE_RUNTIME_ASSETS[target].executableSha256 + await writeFile( + join(dir, `${RELAY_RUNTIME_REF_PREFIX}${executableSha256}`), + `${executableSha256}\n` + ) + } + return { + dir, + digest: hash.digest('hex'), + dispose: () => rm(dir, { recursive: true, force: true }) + } + } catch (error) { + await rm(dir, { recursive: true, force: true }) + throw error + } +} + +/** `~/.orca-remote/runtimes/node-/bin/node` (`…\\node.exe` on Windows), shared with orcad slots. */ +export function pinnedRelayNodePath( + host: RemoteHostPlatform, + remoteRelayDir: string, + target: ServerTarget +): string { + return joinRemotePath( + host, + remoteNodeRuntimeDir(host, remoteRelayDir, target), + ...orcadNodeRuntimeExecutable(target).split('/') + ) +} + +export type PinnedRelayPlan = { + kind: 'pinned-node' + target: ServerTarget + glibc: GlibcVersion | null + fullVersion: string + addons: PinnedRelayAddons + runtimeArchive: () => Promise +} + +export type RelayRuntimeFallbackReason = + | PinnedRuntimeRefusal + /** Rung C only: the host-Node addon relay is POSIX-only. */ + | 'windows_host_unsupported' + | 'target_unresolved' + | 'artifacts_unavailable' + /** No runtime exists for this rung and host (rung B before a compat build ships). */ + | 'runtime_unavailable' + /** Rung C found no host Node >= 18 with the addons' N-API level. */ + | 'host_node_missing' + +export type HostNodeRelayPlan = { + kind: 'host-node' + fallbackReason?: RelayRuntimeFallbackReason + /** Replayed from a cache rather than proved on this connect. */ + remembered?: boolean +} + +/** The pinned path cannot run on this host or client; the deploy retries on the host's Node. */ +export class PinnedRelayFallbackError extends Error { + constructor( + readonly reason: RelayRuntimeFallbackReason, + readonly detail: string, + /** A remembered refusal skips its rung but never proves anything about the next one. */ + readonly remembered = false + ) { + super(`Orca's pinned Node relay is unavailable (${reason}): ${detail}`) + this.name = 'PinnedRelayFallbackError' + } +} + +export function isPinnedRuntimeRefusal(reason: string): reason is PinnedRuntimeRefusal { + return PINNED_RUNTIME_REFUSALS.some((refusal) => refusal === reason) +} + +// Why also in memory: the persisted decision is written only once the ladder settles. +const refusals = new Map() + +function refusalKey(targetId: string, target: ServerTarget): string { + return `${targetId}\0${NODE_RUNTIME_ASSETS[target].executableSha256}` +} + +export function recordPinnedRuntimeRefusal( + targetId: string, + target: ServerTarget, + refusal: PinnedRuntimeRefusal +): void { + refusals.set(refusalKey(targetId, target), refusal) +} + +/** Forgets a refusal a later rung has disproved, so the next connect retries rung A. */ +export function forgetPinnedRuntimeRefusal(targetId: string, target: ServerTarget): void { + refusals.delete(refusalKey(targetId, target)) +} + +export function resetPinnedRuntimeRefusalsForTests(): void { + refusals.clear() +} + +export function logPinnedRelayFallback( + reason: RelayRuntimeFallbackReason, + detail: string +): HostNodeRelayPlan { + console.warn(`[ssh-relay] Pinned Node relay unavailable (${reason}): ${detail}; using host Node`) + return { kind: 'host-node', fallbackReason: reason } +} + +/** + * The host's server target, or a fallback when the libc probe answered with nothing known. + * Why only an answered probe falls back: a lost channel says nothing about the host, and + * descending would launch a second daemon beside a running pinned one, stranding its sessions. + */ +export async function resolvePinnedRelayTargetFacts(options: { + conn: SshConnection + host: RemoteHostPlatform + signal?: AbortSignal +}): Promise { + try { + return await resolveOrcadDeploymentTargetFacts(options) + } catch (error) { + if (!(error instanceof UnidentifiedHostLibcError)) { + throw error + } + return logPinnedRelayFallback('target_unresolved', error.message) + } +} + +export async function planPinnedNodeRelay(options: { + conn: SshConnection + host: RemoteHostPlatform + baseVersion: string + targetId: string + signal?: AbortSignal + /** Already resolved by the ladder; resolved here when absent. */ + facts?: OrcadDeploymentTargetFacts + /** A refusal persisted for this host under a still-matching key (D6). */ + persistedRefusal?: (facts: OrcadDeploymentTargetFacts) => PinnedRuntimeRefusal | null + materializeOrcad?: (target: ServerTarget, signal?: AbortSignal) => Promise + runtimeCacheRoot?: () => string +}): Promise { + const { host, signal } = options + const facts = + options.facts ?? (await resolvePinnedRelayTargetFacts({ conn: options.conn, host, signal })) + if ('kind' in facts) { + return facts + } + const { target, glibc } = facts + const cached = refusals.get(refusalKey(options.targetId, target)) + if (cached) { + return { ...logPinnedRelayFallback(cached, 'refused earlier this session'), remembered: true } + } + const persisted = options.persistedRefusal?.(facts) + if (persisted) { + return { + ...logPinnedRelayFallback(persisted, 'refused on an earlier connect'), + remembered: true + } + } + if (glibc && isGlibcBelow(glibc, PINNED_NODE_GLIBC_FLOOR)) { + recordPinnedRuntimeRefusal(options.targetId, target, 'libc_floor') + return logPinnedRelayFallback( + 'libc_floor', + `host glibc ${glibc.major}.${glibc.minor} is below 2.28` + ) + } + let addons: PinnedRelayAddons + try { + const materialize = + options.materializeOrcad ?? ((t, s) => materializeOrcadArtifact(t, { signal: s })) + addons = await stagePinnedRelayAddons(await materialize(target, signal), target) + } catch (error) { + signal?.throwIfAborted() + return logPinnedRelayFallback( + 'artifacts_unavailable', + error instanceof Error ? error.message : String(error) + ) + } + const cacheRoot = + options.runtimeCacheRoot ?? + ((): string => join(getAppEnvironment().getPath('userData'), 'orcad-artifacts')) + let fullVersion: string + try { + fullVersion = pinnedNodeRelayFullVersion( + options.baseVersion, + NODE_RUNTIME_ASSETS[target].executableSha256, + addons.digest + ) + } catch (error) { + await addons.dispose() + throw error + } + return { + kind: 'pinned-node', + target, + glibc, + fullVersion, + addons, + runtimeArchive: () => + materializeNodeRuntimeArchive(target, cacheRoot(), { signal }).catch((error: unknown) => { + signal?.throwIfAborted() + throw new PinnedRelayFallbackError( + 'artifacts_unavailable', + error instanceof Error ? error.message : String(error) + ) + }) + } +} diff --git a/src/main/ssh/ssh-relay-ripgrep-cache-gc.test.ts b/src/main/ssh/ssh-relay-ripgrep-cache-gc.test.ts index d69bdb497f1a..bc61c5572bab 100644 --- a/src/main/ssh/ssh-relay-ripgrep-cache-gc.test.ts +++ b/src/main/ssh/ssh-relay-ripgrep-cache-gc.test.ts @@ -84,19 +84,6 @@ describe('remote ripgrep cache GC', () => { expect(removedTrees()).toEqual([]) }) - // Why this case exists: the relay directory is named from a hash of the relay bytes, and - // ripgrep is not among them, so a release that bumps only the ripgrep package -- the monthly - // Dependabot PR -- shares a relay directory with its predecessor while minting a new entry. - // With a single marker slot the second client overwrote the first's reference and this pass - // then collected the binary the first client's relay was still running against. - it('keeps both builds when two clients share one relay directory', async () => { - reply([CURRENT, SUPERSEDED], [CURRENT, SUPERSEDED]) - - await gcRemoteRipgrepCache(conn, LINUX, '/home/me', { pinnedEntry: CURRENT }) - - expect(removedTrees()).toEqual([]) - }) - // Why assert the shell and not just the parse: the marker is per entry, so a scan that read a // single fixed filename would silently see only one of the two references above. it('scans every marker in a relay directory, not one fixed name', async () => { diff --git a/src/main/ssh/ssh-relay-runtime-ladder.test.ts b/src/main/ssh/ssh-relay-runtime-ladder.test.ts new file mode 100644 index 000000000000..62de2d7d447b --- /dev/null +++ b/src/main/ssh/ssh-relay-runtime-ladder.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { + compatRelayRuntimeFor, + nextRelayRuntimeStep, + relayRuntimeLadder, + remoteRuntimeUnavailableMessage, + remoteRuntimeUnavailableReason +} from './ssh-relay-runtime-ladder' + +describe('relay runtime ladder (design D6)', () => { + it('keeps the host-npm path alone for Auto and Host Node, and the full ladder for Orca-managed Node', () => { + expect(relayRuntimeLadder('legacy')).toEqual(['legacy']) + expect(relayRuntimeLadder('pinned-node')).toEqual(['A', 'B', 'C', 'legacy', 'D']) + }) + + it('steps to the next rung on an ordinary refusal', () => { + const ladder = relayRuntimeLadder('pinned-node') + expect(nextRelayRuntimeStep(ladder, 'A', 'libc_floor')).toBe('B') + expect(nextRelayRuntimeStep(ladder, 'B', 'runtime_unavailable')).toBe('C') + expect(nextRelayRuntimeStep(ladder, 'C', 'missing_lib')).toBe('legacy') + }) + + it('goes straight to D on noexec, which defeats every rung in the same tree', () => { + const ladder = relayRuntimeLadder('pinned-node') + expect(nextRelayRuntimeStep(ladder, 'A', 'noexec')).toBe('D') + expect(nextRelayRuntimeStep(ladder, 'C', 'noexec')).toBe('D') + }) + + it('lets a remembered noexec skip only its own rung, so a remounted home is re-proved', () => { + const ladder = relayRuntimeLadder('pinned-node') + expect(nextRelayRuntimeStep(ladder, 'A', 'noexec', true)).toBe('B') + }) + + it('skips the npm path when rung C found no host Node, since npm needs one too', () => { + const ladder = relayRuntimeLadder('pinned-node') + expect(nextRelayRuntimeStep(ladder, 'C', 'host_node_missing')).toBe('D') + }) + + it('chooses rung B only when a listed compat runtime serves the host', () => { + const facts = { target: 'linux-x64-glibc' as const, glibc: { major: 2, minor: 17 } } + expect(compatRelayRuntimeFor(facts)).toBeNull() + const catalog = [ + { + id: 'glibc217', + hostTarget: 'linux-x64-glibc' as const, + glibcFloor: { major: 2, minor: 17 } + } + ] + expect(compatRelayRuntimeFor(facts, catalog)?.id).toBe('glibc217') + expect(compatRelayRuntimeFor({ ...facts, glibc: { major: 2, minor: 12 } }, catalog)).toBeNull() + expect(compatRelayRuntimeFor({ target: 'linux-x64-musl', glibc: null }, catalog)).toBeNull() + const muslCatalog = [{ id: 'musl', hostTarget: 'linux-x64-musl' as const, glibcFloor: null }] + expect(compatRelayRuntimeFor({ target: 'linux-x64-musl', glibc: null }, muslCatalog)?.id).toBe( + 'musl' + ) + }) + + it('names the rung D reason in words the user can act on', () => { + expect(remoteRuntimeUnavailableReason('noexec')).toBe('home_noexec') + expect(remoteRuntimeUnavailableReason('host_node_missing')).toBe('no_runtime') + expect(remoteRuntimeUnavailableMessage('home_noexec', 'noexec')).toContain('mounted noexec') + expect(remoteRuntimeUnavailableMessage('no_runtime', 'host_node_missing')).toContain( + 'Install Node.js 18+' + ) + }) +}) diff --git a/src/main/ssh/ssh-relay-runtime-ladder.ts b/src/main/ssh/ssh-relay-runtime-ladder.ts new file mode 100644 index 000000000000..ce6261ba7298 --- /dev/null +++ b/src/main/ssh/ssh-relay-runtime-ladder.ts @@ -0,0 +1,96 @@ +/** + * The design D6 fallback ladder for the relay runtime, as data plus a pure step function: + * + * A Orca's pinned Node + slot prebuilds + * B a compat pinned Node + compat addons (chosen only when a compat runtime exists) + * C the host's Node >= 18 + Orca's N-API prebuilds, no npm + * legacy the host's Node + npm install (kept until the default flips) + * D nothing runs: fail the connect with the classified reason + * + * The ladder steps down only on a classified refusal (a `PinnedRelayFallbackError`); an + * unverifiable probe or self-test throws and the next connect retries the same rung. + */ +import type { ServerTarget } from '../../shared/node-runtime-pin' +import type { SshRemoteRuntime, SshRemoteRuntimeRung } from '../../shared/ssh-types' +import type { GlibcVersion } from './orcad-deployment-target' +import { isGlibcBelow, type RelayRuntimeFallbackReason } from './ssh-relay-pinned-node' + +export type RelayRuntimeStep = SshRemoteRuntimeRung + +export type CompatRelayRuntime = { + id: string + /** The host target this runtime serves, e.g. linux-x64-glibc for a glibc 2.17 build. */ + hostTarget: ServerTarget + /** Null for a musl or darwin target, which has no glibc to compare. */ + glibcFloor: GlibcVersion | null +} + +/** Empty until a compat runtime ships; rung B is then chosen from this list alone. */ +export const COMPAT_RELAY_RUNTIMES: readonly CompatRelayRuntime[] = [] + +export function relayRuntimeLadder(runtime: SshRemoteRuntime): readonly RelayRuntimeStep[] { + return runtime === 'pinned-node' ? ['A', 'B', 'C', 'legacy', 'D'] : ['legacy'] +} + +export function compatRelayRuntimeFor( + facts: { target: ServerTarget; glibc: GlibcVersion | null }, + catalog: readonly CompatRelayRuntime[] = COMPAT_RELAY_RUNTIMES +): CompatRelayRuntime | null { + return ( + catalog.find( + (runtime) => + runtime.hostTarget === facts.target && + (runtime.glibcFloor === null || + (facts.glibc !== null && !isGlibcBelow(facts.glibc, runtime.glibcFloor))) + ) ?? null + ) +} + +/** Why a rung could not run; the refusal classes plus reasons found before anything ran. */ +export type RelayRuntimeStepReason = RelayRuntimeFallbackReason + +/** + * noexec defeats every rung, because each loads addons from the same `~/.orca-remote` tree; + * no host Node rules out the npm path too, since it needs a host Node as well. A remembered + * refusal only skips its own rung: the mount may have changed since it was proved. + */ +export function nextRelayRuntimeStep( + ladder: readonly RelayRuntimeStep[], + current: RelayRuntimeStep, + reason: RelayRuntimeStepReason, + remembered = false +): RelayRuntimeStep { + if ((reason === 'noexec' && !remembered) || (current === 'C' && reason === 'host_node_missing')) { + return 'D' + } + const index = ladder.indexOf(current) + return ladder[index + 1] ?? 'D' +} + +/** The machine-readable part of a rung D failure; the message is what the user reads. */ +export const REMOTE_RUNTIME_UNAVAILABLE_REASONS = ['home_noexec', 'no_runtime'] as const +export type RemoteRuntimeUnavailableReason = (typeof REMOTE_RUNTIME_UNAVAILABLE_REASONS)[number] + +export function remoteRuntimeUnavailableReason( + lastReason: RelayRuntimeStepReason | null +): RemoteRuntimeUnavailableReason { + return lastReason === 'noexec' ? 'home_noexec' : 'no_runtime' +} + +const REMOTE_RUNTIME_UNAVAILABLE_MESSAGES: Record = { + home_noexec: + "Orca can't run its remote runtime on this host: the home directory is mounted noexec, so " + + 'nothing under ~/.orca-remote may execute. Remote terminals and file browsing are ' + + 'unavailable until an administrator allows exec there.', + no_runtime: + "Orca can't run its remote runtime on this host: its bundled Node.js was refused and no " + + 'Node.js 18 or newer was found on the host. Install Node.js 18+ on the host, then reconnect.' +} + +export function remoteRuntimeUnavailableMessage( + reason: RemoteRuntimeUnavailableReason, + refusal: RelayRuntimeStepReason | null +): string { + const base = REMOTE_RUNTIME_UNAVAILABLE_MESSAGES[reason] + return refusal && reason !== 'home_noexec' ? `${base} (Orca's Node: ${refusal})` : base +} diff --git a/src/main/ssh/ssh-relay-runtime-resolution.test.ts b/src/main/ssh/ssh-relay-runtime-resolution.test.ts new file mode 100644 index 000000000000..af05a27af9b7 --- /dev/null +++ b/src/main/ssh/ssh-relay-runtime-resolution.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshRemoteRuntimeResolution } from '../../shared/ssh-types' + +vi.mock('../../shared/app-environment', () => ({ + getAppEnvironment: () => ({ getVersion: () => '1.4.0' }) +})) +vi.mock('./ssh-remote-runtime-telemetry', () => ({ trackSshRemoteRuntimeResolved: vi.fn() })) + +import { + RelayRuntimeLadderRun, + type RelayRuntimeDecisionStore +} from './ssh-relay-runtime-resolution' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const facts = { target: 'linux-x64-glibc' as const, glibc: { major: 2, minor: 31 } } + +function memoryStore(): RelayRuntimeDecisionStore & { value?: SshRemoteRuntimeResolution } { + const store: RelayRuntimeDecisionStore & { value?: SshRemoteRuntimeResolution } = { + read: () => store.value, + write: (_id, resolution) => { + store.value = resolution + } + } + return store +} + +function rememberedNoexecRun(store: RelayRuntimeDecisionStore): RelayRuntimeLadderRun { + const run = new RelayRuntimeLadderRun('ssh-1', store) + run.host = getRemoteHostPlatform('linux-x64') + run.facts = facts + run.refused('A', 'noexec') + return run +} + +describe('RelayRuntimeLadderRun', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('persists a rung A refusal and replays it only under a matching key', () => { + const store = memoryStore() + rememberedNoexecRun(store).settle('D') + expect(store.value).toMatchObject({ rung: 'D', pinnedRefusal: 'noexec', glibc: '2.31' }) + const replay = new RelayRuntimeLadderRun('ssh-1', store) + expect(replay.persistedPinnedRefusal(facts)).toBe('noexec') + expect(replay.persistedPinnedRefusal({ ...facts, glibc: { major: 2, minor: 35 } })).toBeNull() + }) + + it('drops a remembered noexec once rung C self-tests addons from the same tree', () => { + const store = memoryStore() + const run = rememberedNoexecRun(store) + run.selfTest = 'passed' + run.settle('C') + expect(store.value?.rung).toBe('C') + expect(store.value).not.toHaveProperty('pinnedRefusal') + }) + + it('keeps a remembered noexec when rung C launched warm without a self-test', () => { + const store = memoryStore() + rememberedNoexecRun(store).settle('C') + expect(store.value?.pinnedRefusal).toBe('noexec') + }) +}) diff --git a/src/main/ssh/ssh-relay-runtime-resolution.ts b/src/main/ssh/ssh-relay-runtime-resolution.ts new file mode 100644 index 000000000000..828489241449 --- /dev/null +++ b/src/main/ssh/ssh-relay-runtime-resolution.ts @@ -0,0 +1,194 @@ +/** + * One pass down the relay runtime ladder (design D6): what it learned about the host, the + * decision it persists per host, the telemetry it sends, and the rung D failure it raises. + */ +import { getAppEnvironment } from '../../shared/app-environment' +import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin' +import type { + SshRemoteRuntimeResolution, + SshRemoteRuntimeRung, + SshTarget +} from '../../shared/ssh-types' +import type { TerminalUnavailableCause } from '../../shared/terminal-unavailable-cause' +import type { OrcadDeploymentTargetFacts } from './orcad-deployment-target' +import { + forgetPinnedRuntimeRefusal, + isPinnedRuntimeRefusal, + type RelayRuntimeFallbackReason +} from './ssh-relay-pinned-node' +import { + remoteRuntimeUnavailableMessage, + remoteRuntimeUnavailableReason, + type RelayRuntimeStep, + type RemoteRuntimeUnavailableReason +} from './ssh-relay-runtime-ladder' +import type { PinnedRuntimeRefusal } from './ssh-relay-runtime-self-test' +import type { RemoteHostPlatform } from './ssh-remote-platform' +import type { HostNodeVersion } from './ssh-remote-node-toolchain-probe' +import { trackSshRemoteRuntimeResolved } from './ssh-remote-runtime-telemetry' + +export type RelayRuntimeSelfTestOutcome = 'passed' | 'refused' | 'failed' | 'not_run' +export type RelayRuntimeTransfer = 'uploaded' | 'cached' | 'none' + +export type RelayRuntimeDecisionStore = { + read: (targetId: string) => SshRemoteRuntimeResolution | undefined + write: (targetId: string, resolution: SshRemoteRuntimeResolution) => void +} + +type DecisionKey = Pick + +export function relayRuntimeDecisionKey(facts: OrcadDeploymentTargetFacts): DecisionKey { + return { + glibc: facts.glibc ? `${facts.glibc.major}.${facts.glibc.minor}` : null, + runtimeSha256: NODE_RUNTIME_ASSETS[facts.target].executableSha256, + orcaMajor: orcaMajorVersion() + } +} + +function orcaMajorVersion(): number { + const major = Number.parseInt(getAppEnvironment().getVersion().split('.')[0] ?? '', 10) + return Number.isFinite(major) ? major : 0 +} + +function sameKey(a: DecisionKey, b: DecisionKey): boolean { + return a.glibc === b.glibc && a.runtimeSha256 === b.runtimeSha256 && a.orcaMajor === b.orcaMajor +} + +/** The persisted rung A refusal, only while its key still describes this host and build. */ +export function persistedPinnedRefusal( + decision: SshRemoteRuntimeResolution | undefined, + facts: OrcadDeploymentTargetFacts +): PinnedRuntimeRefusal | null { + if (!decision?.pinnedRefusal || !sameKey(decision, relayRuntimeDecisionKey(facts))) { + return null + } + return isPinnedRuntimeRefusal(decision.pinnedRefusal) ? decision.pinnedRefusal : null +} + +export class RelayRuntimeLadderRun { + readonly startedAt = Date.now() + host: RemoteHostPlatform | null = null + facts: OrcadDeploymentTargetFacts | null = null + firstRefusal: RelayRuntimeFallbackReason | null = null + lastRefusal: RelayRuntimeFallbackReason | null = null + pinnedRefusal: PinnedRuntimeRefusal | null = null + selfTest: RelayRuntimeSelfTestOutcome = 'not_run' + runtimeTransfer: RelayRuntimeTransfer = 'none' + hostNode: HostNodeVersion | null = null + + constructor( + readonly targetId: string, + private readonly store: RelayRuntimeDecisionStore | null + ) {} + + persistedPinnedRefusal(facts: OrcadDeploymentTargetFacts): PinnedRuntimeRefusal | null { + return this.store ? persistedPinnedRefusal(this.store.read(this.targetId), facts) : null + } + + refused(step: RelayRuntimeStep, reason: RelayRuntimeFallbackReason): void { + this.firstRefusal ??= reason + this.lastRefusal = reason + if (step === 'A' && isPinnedRuntimeRefusal(reason)) { + this.pinnedRefusal = reason + } + } + + /** Each rung reports its own self-test; a rung D report keeps the one that refused. */ + enter(step: RelayRuntimeStep): void { + if (step !== 'D') { + this.selfTest = 'not_run' + } + } + + settle(rung: SshRemoteRuntimeRung): void { + // Why: C's self-test loaded addons from the same tree, which disproves a remembered noexec. + if (rung === 'C' && this.selfTest === 'passed' && this.pinnedRefusal === 'noexec') { + this.pinnedRefusal = null + if (this.facts) { + forgetPinnedRuntimeRefusal(this.targetId, this.facts.target) + } + } + this.persist(rung) + if (this.host) { + trackSshRemoteRuntimeResolved(this.targetId, { + rung, + host: this.host, + facts: this.facts, + firstRefusal: this.firstRefusal, + selfTest: this.selfTest, + runtimeTransfer: this.runtimeTransfer, + hostNode: rung === 'C' ? this.hostNode : null, + durationMs: Date.now() - this.startedAt + }) + } + } + + private persist(rung: SshRemoteRuntimeRung): void { + // Why facts-gated: the key names the host's glibc; without an answered probe there is none. + if (!this.store || !this.facts) { + return + } + const next: SshRemoteRuntimeResolution = { + rung, + ...(this.pinnedRefusal ? { pinnedRefusal: this.pinnedRefusal } : {}), + ...relayRuntimeDecisionKey(this.facts) + } + const previous = this.store.read(this.targetId) + if (JSON.stringify(previous) !== JSON.stringify(next)) { + try { + this.store.write(this.targetId, next) + } catch (error) { + // A lost cache costs one more rung A attempt on the next connect, never the connect. + console.warn('[ssh-relay] Could not persist the relay runtime decision:', error) + } + } + } +} + +/** Rung D: no runtime runs on this host. Carries the classified cause for structured readers. */ +export class RemoteRuntimeUnavailableError extends Error { + readonly data: TerminalUnavailableCause + + constructor( + readonly reason: RemoteRuntimeUnavailableReason, + run: RelayRuntimeLadderRun + ) { + super(remoteRuntimeUnavailableMessage(reason, run.firstRefusal)) + this.name = 'RemoteRuntimeUnavailableError' + const glibc = run.facts?.glibc + this.data = { + status: 'blocked', + reason, + detail: `last refusal: ${run.lastRefusal ?? 'none'}`, + repairable: false, + host: { + platform: run.host?.os ?? 'unknown', + arch: run.host?.arch ?? 'unknown', + libc: run.facts?.target.endsWith('-musl') + ? 'musl' + : run.facts?.target.endsWith('-glibc') + ? 'glibc' + : 'none', + ...(glibc ? { glibcVersion: `${glibc.major}.${glibc.minor}` } : {}), + nodeAbi: 'none', + nodeVersion: 'none' + } + } + } +} + +export function remoteRuntimeUnavailableError(run: RelayRuntimeLadderRun): Error { + return new RemoteRuntimeUnavailableError(remoteRuntimeUnavailableReason(run.lastRefusal), run) +} + +export function sshTargetRelayRuntimeDecisionStore(registry: { + getTarget: (id: string) => SshTarget | undefined + updateTarget: (id: string, updates: Partial>) => unknown +}): RelayRuntimeDecisionStore { + return { + read: (targetId) => registry.getTarget(targetId)?.remoteRuntimeResolution, + write: (targetId, resolution) => { + registry.updateTarget(targetId, { remoteRuntimeResolution: resolution }) + } + } +} diff --git a/src/main/ssh/ssh-relay-runtime-self-test.test.ts b/src/main/ssh/ssh-relay-runtime-self-test.test.ts new file mode 100644 index 000000000000..9b1e326759fb --- /dev/null +++ b/src/main/ssh/ssh-relay-runtime-self-test.test.ts @@ -0,0 +1,300 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runProcessSync } from '../../shared/child-process/run-process' +import { NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin' +import { RELAY_RUNTIME_SELF_TEST_PREFIX } from '../../shared/relay-runtime-self-test-report' +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { SSH_EXEC_TIMEOUT_CODE } from './ssh-relay-exec-command' +import { + classifyPinnedRuntimeFailure, + evaluatePinnedRuntimeVersion, + evaluateRelayRuntimeSelfTest, + evaluateWindowsRelayRuntimeSelfTest, + pinnedRuntimeVersionCommand, + relayRuntimeSelfTestCommand, + runPinnedRuntimeSelfTest, + windowsRelayRuntimeSelfTestCommand +} from './ssh-relay-runtime-self-test' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { decodeRemotePowerShellScript } from './ssh-remote-powershell' + +vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn() })) + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: All connection operations are mocked. +const conn = {} as SshConnection +const directories: string[] = [] +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function sh(command: string): string { + return runProcessSync({ program: '/bin/sh', args: ['-c', command], timeoutMs: 30_000 }).stdout +} + +function report(nonce: string, fields: Record): string { + return `${RELAY_RUNTIME_SELF_TEST_PREFIX}${JSON.stringify({ + nonce, + node: `v${NODE_RUNTIME_PIN.version}`, + napi: '10', + glibcVersionRuntime: '2.31', + runtime: 'pinned-node', + ...fields + })}` +} + +describe('pinned runtime refusal classification', () => { + it.each([ + [132, '', 'illegal_instruction'], + [null, 'Illegal instruction (core dumped)', 'illegal_instruction'], + [126, 'sh: 1: /home/u/.orca-remote/runtimes/node-x/bin/node: Permission denied', 'noexec'], + [ + null, + 'Error: /r/node_modules/node-pty/build/Release/pty.node: failed to map segment from shared object', + 'noexec' + ], + [1, "node: /lib64/libc.so.6: version `GLIBC_2.28' not found (required by node)", 'libc_floor'], + [1, "/lib64/libstdc++.so.6: version `GLIBCXX_3.4.26' not found", 'libc_floor'], + [127, 'sh: /home/u/.orca-remote/runtimes/node-x/bin/node: not found', 'wrong_libc'], + [null, 'Error relocating pty.node: __snprintf_chk: symbol not found', 'wrong_libc'], + [ + 1, + 'Error loading shared library ld-linux-x86-64.so.2: No such file or directory', + 'wrong_libc' + ], + [ + 127, + 'node: error while loading shared libraries: libatomic.so.1: cannot open shared object file: No such file or directory', + 'missing_lib' + ] + ])('classifies exit %j with %j as %s', (status, output, refusal) => { + expect(classifyPinnedRuntimeFailure(status, output)).toBe(refusal) + }) + + it.each([ + [ + -1, + "Program 'node.exe' failed to run: Operation did not complete successfully because the file contains a virus or potentially unwanted software", + 'security_software' + ], + [ + null, + 'bundled ConPTY file missing after upload: C:\\r\\node_modules\\node-pty\\build\\Release\\conpty\\OpenConsole.exe', + 'security_software' + ], + [-1, "Program 'node.exe' failed to run: This program is blocked by group policy.", 'noexec'], + [ + -1, + 'Your organization used Device Guard to block this app. An Application Control policy has blocked this file.', + 'noexec' + ] + ])('classifies Windows exit %j with %j as %s', (status, output, refusal) => { + expect(classifyPinnedRuntimeFailure(status, output)).toBe(refusal) + }) + + it('leaves an unrecognized failure unclassified', () => { + expect(classifyPinnedRuntimeFailure(1, 'TypeError: something else')).toBeNull() + }) +}) + +describe('self-test commands on a real shell', () => { + function stage(): string { + const root = mkdtempSync(join(tmpdir(), 'relay self test ')) + directories.push(root) + return root + } + + it('accepts only the pinned version from node --version', () => { + const root = stage() + const node = join(root, 'node') + writeFileSync(node, `#!/bin/sh\necho v${NODE_RUNTIME_PIN.version}\n`, { mode: 0o755 }) + expect(evaluatePinnedRuntimeVersion(sh(pinnedRuntimeVersionCommand(node)))).toBeNull() + + writeFileSync(node, '#!/bin/sh\necho v18.20.0\n', { mode: 0o755 }) + expect(evaluatePinnedRuntimeVersion(sh(pinnedRuntimeVersionCommand(node)))).toMatchObject({ + verdict: 'failed' + }) + }) + + it('reports a non-executable runtime as noexec from the shell exit status', () => { + const root = stage() + const node = join(root, 'node') + writeFileSync(node, '#!/bin/sh\necho v0\n', { mode: 0o644 }) + expect(evaluatePinnedRuntimeVersion(sh(pinnedRuntimeVersionCommand(node)))).toMatchObject({ + verdict: 'refused', + refusal: 'noexec' + }) + }) + + it('runs relay.js with the self-test flag and nonce from the relay dir', () => { + const root = stage() + const relayDir = join(root, 'relay-0.1.0+abc') + mkdirSync(relayDir) + writeFileSync( + join(relayDir, 'relay.js'), + `const i = process.argv.indexOf('--orca-runtime-selftest');\n` + + `console.log(${JSON.stringify(RELAY_RUNTIME_SELF_TEST_PREFIX)} + JSON.stringify({nonce: process.argv[i + 1], ok: true, cwd: process.cwd()}))\n` + ) + const output = sh(relayRuntimeSelfTestCommand(relayDir, process.execPath, 'nonce-1')) + const verdict = evaluateRelayRuntimeSelfTest(output, 'nonce-1') + expect(verdict.verdict).toBe('passed') + expect(evaluateRelayRuntimeSelfTest(output, 'other-nonce').verdict).toBe('failed') + }) + + it('classifies a relay process killed by SIGILL', () => { + const root = stage() + writeFileSync(join(root, 'relay.js'), "process.kill(process.pid, 'SIGILL')\n") + const output = sh(relayRuntimeSelfTestCommand(root, process.execPath, 'n')) + expect(evaluateRelayRuntimeSelfTest(output, 'n')).toMatchObject({ + verdict: 'refused', + refusal: 'illegal_instruction' + }) + }) +}) + +describe('relay self-test report evaluation', () => { + it('classifies the loader message a failed addon load reports', () => { + const output = `ORCA_RUNTIME_EXIT=0\n${report('n', { + ok: false, + stage: 'load', + error: "/lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.33' not found" + })}` + expect(evaluateRelayRuntimeSelfTest(output, 'n')).toMatchObject({ + verdict: 'refused', + refusal: 'libc_floor' + }) + }) + + it('never turns a PTY spawn failure into a runtime refusal', () => { + const output = `ORCA_RUNTIME_EXIT=0\n${report('n', { + ok: false, + stage: 'spawn', + error: 'posix_openpt: Permission denied' + })}` + expect(evaluateRelayRuntimeSelfTest(output, 'n').verdict).toBe('failed') + }) +}) + +describe('Windows relay self-test', () => { + it('is one encoded powershell.exe line that runs relay.js on node.exe from the relay dir', () => { + const command = windowsRelayRuntimeSelfTestCommand( + 'C:/Users/u/.orca-remote/relay-0.1.0+abc', + "C:/Users/o'brien/.orca-remote/runtimes/node-x/node.exe", + 'feed' + ) + expect(command).toMatch(/^powershell\.exe -NoProfile -NonInteractive -EncodedCommand \S+$/) + expect(decodeRemotePowerShellScript(command)).toMatchInlineSnapshot(` + "Set-Location -LiteralPath 'C:/Users/u/.orca-remote/relay-0.1.0+abc' + $out = ''; $status = $null + try { $out = ((& 'C:/Users/o''brien/.orca-remote/runtimes/node-x/node.exe' 'relay.js' '--orca-runtime-selftest' 'feed' 2>&1) | ForEach-Object { "$_" }) -join "\`n"; $status = $LASTEXITCODE } catch { $status = -1; $out = $_.Exception.Message } + Write-Output ('ORCA_RUNTIME_EXIT=' + $status) + Write-Output ($out.Substring(0, [Math]::Min(16000, $out.Length)))" + `) + }) + + it('requires the report to name the pinned Node, since no separate --version runs', () => { + expect( + evaluateWindowsRelayRuntimeSelfTest( + `ORCA_RUNTIME_EXIT=0\r\n${report('n', { ok: true })}`, + 'n' + ) + ).toMatchObject({ verdict: 'passed' }) + expect( + evaluateWindowsRelayRuntimeSelfTest( + `ORCA_RUNTIME_EXIT=0\r\n${report('n', { ok: true, node: 'v22.16.0' })}`, + 'n' + ) + ).toMatchObject({ verdict: 'failed' }) + }) + + it('runs in a single unwrapped exec on a Windows host', async () => { + vi.mocked(execCommand).mockReset() + vi.mocked(execCommand).mockImplementationOnce(async (_conn, command) => { + const nonce = /'--orca-runtime-selftest' '([0-9a-f]+)'/.exec( + decodeRemotePowerShellScript(command) + )?.[1] + return `ORCA_RUNTIME_EXIT=0\r\n${report(nonce ?? '', { ok: true })}\r\n` + }) + await expect( + runPinnedRuntimeSelfTest(conn, 'C:/r', 'C:/n/node.exe', undefined, { + host: getRemoteHostPlatform('win32-x64') + }) + ).resolves.toMatchObject({ verdict: 'passed' }) + expect(execCommand).toHaveBeenCalledTimes(1) + expect(vi.mocked(execCommand).mock.calls[0][2]).toMatchObject({ wrapCommand: false }) + }) + + it('refuses a runtime whose ConPTY pair was quarantined after upload', () => { + const output = `ORCA_RUNTIME_EXIT=0\r\n${report('n', { + ok: false, + stage: 'load', + error: 'bundled ConPTY file missing after upload: C:/r/conpty/OpenConsole.exe' + })}` + expect(evaluateWindowsRelayRuntimeSelfTest(output, 'n')).toMatchObject({ + verdict: 'refused', + refusal: 'security_software' + }) + }) +}) + +describe('runPinnedRuntimeSelfTest', () => { + beforeEach(() => { + vi.mocked(execCommand).mockReset() + }) + + const versionOk = `ORCA_RUNTIME_EXIT=0\nv${NODE_RUNTIME_PIN.version}` + + it('passes when both steps answer', async () => { + vi.mocked(execCommand) + .mockResolvedValueOnce(versionOk) + .mockImplementationOnce(async (_conn, command) => { + const nonce = /--orca-runtime-selftest '([0-9a-f]+)'/.exec(command)?.[1] ?? '' + return `ORCA_RUNTIME_EXIT=0\n${report(nonce, { ok: true })}` + }) + await expect(runPinnedRuntimeSelfTest(conn, '/r', '/n')).resolves.toMatchObject({ + verdict: 'passed' + }) + }) + + it('treats a timeout as unverifiable and retries once, never as a refusal', async () => { + const timeout = Object.assign(new Error('timed out'), { code: SSH_EXEC_TIMEOUT_CODE }) + vi.mocked(execCommand).mockImplementation(async () => { + throw timeout + }) + const verdict = await runPinnedRuntimeSelfTest(conn, '/r', '/n') + expect(verdict.verdict).toBe('unverifiable') + expect(execCommand).toHaveBeenCalledTimes(2) + }) + + it('does not stack a retry on an unconfirmed teardown', async () => { + const lost = Object.assign(new Error('channel lost'), { sshChannelCloseConfirmed: false }) + vi.mocked(execCommand).mockImplementation(async () => { + throw lost + }) + const verdict = await runPinnedRuntimeSelfTest(conn, '/r', '/n') + expect(verdict.verdict).toBe('unverifiable') + expect(verdict.verdict === 'unverifiable' && verdict.cause).toBe(lost) + expect(execCommand).toHaveBeenCalledTimes(1) + }) + + it('does not retry an answered refusal', async () => { + vi.mocked(execCommand).mockResolvedValue('ORCA_RUNTIME_EXIT=126\nPermission denied') + await expect(runPinnedRuntimeSelfTest(conn, '/r', '/n')).resolves.toMatchObject({ + verdict: 'refused', + refusal: 'noexec' + }) + expect(execCommand).toHaveBeenCalledTimes(1) + }) + + it('bounds each step by the remaining 30 s budget', async () => { + vi.mocked(execCommand).mockResolvedValue('ORCA_RUNTIME_EXIT=126\nPermission denied') + await runPinnedRuntimeSelfTest(conn, '/r', '/n') + const timeoutMs = vi.mocked(execCommand).mock.calls[0][2]?.timeoutMs ?? 0 + expect(timeoutMs).toBeGreaterThan(29_000) + expect(timeoutMs).toBeLessThanOrEqual(30_000) + }) +}) diff --git a/src/main/ssh/ssh-relay-runtime-self-test.ts b/src/main/ssh/ssh-relay-runtime-self-test.ts new file mode 100644 index 000000000000..09d94807c994 --- /dev/null +++ b/src/main/ssh/ssh-relay-runtime-self-test.ts @@ -0,0 +1,296 @@ +/** + * The 30 s self-test a pinned-Node relay directory passes before launch (design D5): + * `node --version`, then `relay.js --orca-runtime-selftest `. + * + * Verdicts follow docs/reference/ssh-execution-boundary.md: a timeout or lost channel is + * `unverifiable` and never a reason to step down to another runtime; only a classified + * refusal from a process that actually ran is. + */ +import { randomBytes } from 'node:crypto' +import { NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin' +import { + RELAY_RUNTIME_SELF_TEST_FLAG, + RELAY_RUNTIME_SELF_TEST_PREFIX, + type RelayRuntimeSelfTestReport +} from '../../shared/relay-runtime-self-test-report' +import type { SshConnection } from './ssh-connection' +import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' +import { shellEscape } from './ssh-connection-utils' +import { execCommand } from './ssh-relay-deploy-helpers' +import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' +import { + parseRemoteRuntimeExitReport, + REMOTE_NODE_RUNTIME_EXIT_PREFIX +} from './orcad-remote-node-runtime' + +export const PINNED_RUNTIME_REFUSALS = [ + 'noexec', + 'missing_lib', + 'libc_floor', + 'illegal_instruction', + 'wrong_libc', + // Windows: AV or application control removed, rewrote or blocked verified bytes. + 'security_software' +] as const +export type PinnedRuntimeRefusal = (typeof PINNED_RUNTIME_REFUSALS)[number] + +export type PinnedRuntimeSelfTestVerdict = + | { verdict: 'passed'; report: RelayRuntimeSelfTestReport & { ok: true } } + | { verdict: 'refused'; refusal: PinnedRuntimeRefusal; detail: string } + | { verdict: 'unverifiable'; detail: string; cause?: unknown } + /** It ran and failed in a way no refusal class covers: not proof the runtime cannot work here. */ + | { verdict: 'failed'; detail: string } + +export const RELAY_RUNTIME_SELF_TEST_BUDGET_MS = 30_000 +const SIGILL_EXIT_STATUS = 128 + 4 + +/** Map what the loader or shell said to a refusal class; null when nothing matches. */ +export function classifyPinnedRuntimeFailure( + exitStatus: number | null, + output: string +): PinnedRuntimeRefusal | null { + if (/contains a virus|potentially unwanted software|missing after upload/i.test(output)) { + return 'security_software' + } + // Windows application control refuses to start binaries under the user profile. + if ( + /blocked by group policy|Application Control policy|AppLocker|software restriction/i.test( + output + ) + ) { + return 'noexec' + } + if (exitStatus === SIGILL_EXIT_STATUS || /illegal instruction/i.test(output)) { + return 'illegal_instruction' + } + if ( + exitStatus === 126 || + /permission denied|failed to map segment from shared object/i.test(output) + ) { + return 'noexec' + } + if (/\b(?:GLIBC|GLIBCXX|CXXABI)_[0-9.]+'? not found/.test(output)) { + return 'libc_floor' + } + // A glibc binary on musl (or the reverse) fails in the ELF interpreter or on relocation. + if (/ld-linux[\w.-]*\.so|ld-musl[\w.-]*\.so|Error relocating|symbol not found/i.test(output)) { + return 'wrong_libc' + } + if ( + /error while loading shared libraries|cannot open shared object file|Error loading shared library/i.test( + output + ) + ) { + return 'missing_lib' + } + // The shell's "not found" for a file that exists: its ELF interpreter is absent. + if (exitStatus === 127 && /no such file or directory|not found/i.test(output)) { + return 'wrong_libc' + } + return null +} + +function wrapWithExitReport(command: string): string { + // Why always exit 0: the exit status is evidence to classify, and execCommand drops stdout on failure. + return ( + `orca_rt_out=$(${command} 2>&1); orca_rt_status=$?; ` + + `echo "${REMOTE_NODE_RUNTIME_EXIT_PREFIX}$orca_rt_status"; printf '%s\\n' "$orca_rt_out" | head -c 16000` + ) +} + +export function pinnedRuntimeVersionCommand(nodePath: string): string { + return wrapWithExitReport(`${shellEscape(nodePath)} --version`) +} + +export function relayRuntimeSelfTestCommand( + relayDir: string, + nodePath: string, + nonce: string +): string { + return `cd ${shellEscape(relayDir)} && ${wrapWithExitReport( + `${shellEscape(nodePath)} relay.js ${RELAY_RUNTIME_SELF_TEST_FLAG} ${shellEscape(nonce)}` + )}` +} + +/** + * One powershell.exe for the whole Windows self-test: the promote step already ran + * `node.exe --version`, and the report's `node` field repeats it, so no second spawn. + */ +export function windowsRelayRuntimeSelfTestCommand( + relayDir: string, + nodePath: string, + nonce: string +): string { + return powerShellCommand( + [ + `Set-Location -LiteralPath ${powerShellLiteral(relayDir)}`, + "$out = ''; $status = $null", + `try { $out = ((& ${powerShellLiteral(nodePath)} 'relay.js' ${powerShellLiteral(RELAY_RUNTIME_SELF_TEST_FLAG)} ${powerShellLiteral(nonce)} 2>&1) | ForEach-Object { "$_" }) -join "\`n"; $status = $LASTEXITCODE } catch { $status = -1; $out = $_.Exception.Message }`, + `Write-Output (${powerShellLiteral(REMOTE_NODE_RUNTIME_EXIT_PREFIX)} + $status)`, + 'Write-Output ($out.Substring(0, [Math]::Min(16000, $out.Length)))' + ].join('\n') + ) +} + +function readReport(output: string, nonce: string): RelayRuntimeSelfTestReport | null { + for (const line of output.split('\n')) { + if (!line.startsWith(RELAY_RUNTIME_SELF_TEST_PREFIX)) { + continue + } + try { + const parsed: unknown = JSON.parse(line.slice(RELAY_RUNTIME_SELF_TEST_PREFIX.length)) + if ( + parsed && + typeof parsed === 'object' && + 'nonce' in parsed && + parsed.nonce === nonce && + 'ok' in parsed && + typeof parsed.ok === 'boolean' + ) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the nonce ties this line to our own relay build, which prints exactly RelayRuntimeSelfTestReport; the ok discriminant is checked above. + return parsed as RelayRuntimeSelfTestReport + } + } catch { + return null + } + } + return null +} + +function refusedOrFailed( + exitStatus: number | null, + output: string, + step: string +): PinnedRuntimeSelfTestVerdict { + const refusal = classifyPinnedRuntimeFailure(exitStatus, output) + const detail = `${step} (exit ${exitStatus ?? 'unknown'}): ${output.slice(0, 2000)}` + return refusal ? { verdict: 'refused', refusal, detail } : { verdict: 'failed', detail } +} + +/** Null when the runtime printed the pinned version; otherwise its refusal or failure. */ +export function evaluatePinnedRuntimeVersion( + versionOutput: string +): PinnedRuntimeSelfTestVerdict | null { + const version = parseRemoteRuntimeExitReport(versionOutput) + if (version.exitStatus === 0 && version.output === `v${NODE_RUNTIME_PIN.version}`) { + return null + } + return refusedOrFailed(version.exitStatus, version.output, 'node --version') +} + +export function evaluateRelayRuntimeSelfTest( + selfTestOutput: string, + nonce: string +): PinnedRuntimeSelfTestVerdict { + const selfTest = parseRemoteRuntimeExitReport(selfTestOutput) + const report = readReport(selfTest.output, nonce) + if (report?.ok) { + return { verdict: 'passed', report } + } + if (report && !report.ok && report.stage === 'spawn') { + // Why never a refusal: the addon loaded, so this runtime and libc are not what failed. + return { verdict: 'failed', detail: `relay self-test could not open a PTY: ${report.error}` } + } + const evidence = report && !report.ok ? report.error : selfTest.output + return refusedOrFailed(selfTest.exitStatus, evidence, 'relay self-test') +} + +/** The Windows run skips the separate `--version` step, so the report must name the pin. */ +export function evaluateWindowsRelayRuntimeSelfTest( + selfTestOutput: string, + nonce: string +): PinnedRuntimeSelfTestVerdict { + const verdict = evaluateRelayRuntimeSelfTest(selfTestOutput, nonce) + if (verdict.verdict === 'passed' && verdict.report.node !== `v${NODE_RUNTIME_PIN.version}`) { + return { + verdict: 'failed', + detail: `relay self-test ran on ${verdict.report.node}, not the pin` + } + } + return verdict +} + +export type RelayRuntimeSelfTestOptions = { + attempts?: number + host?: RemoteHostPlatform + /** False on rung C: the host's own Node has no pinned version to match. */ + expectPinnedVersion?: boolean +} + +async function runSelfTestOnce( + conn: SshConnection, + relayDir: string, + nodePath: string, + signal: AbortSignal | undefined, + options: RelayRuntimeSelfTestOptions +): Promise { + const deadline = Date.now() + RELAY_RUNTIME_SELF_TEST_BUDGET_MS + const remaining = (): number => Math.max(1_000, deadline - Date.now()) + const nonce = randomBytes(12).toString('hex') + try { + if (options.host && isWindowsRemoteHost(options.host)) { + const output = await execCommand( + conn, + windowsRelayRuntimeSelfTestCommand(relayDir, nodePath, nonce), + { timeoutMs: remaining(), wrapCommand: false, signal } + ) + return options.expectPinnedVersion === false + ? evaluateRelayRuntimeSelfTest(output, nonce) + : evaluateWindowsRelayRuntimeSelfTest(output, nonce) + } + const versionVerdict = + options.expectPinnedVersion === false + ? null + : evaluatePinnedRuntimeVersion( + await execCommand(conn, pinnedRuntimeVersionCommand(nodePath), { + timeoutMs: remaining(), + signal + }) + ) + if (versionVerdict) { + return versionVerdict + } + return evaluateRelayRuntimeSelfTest( + await execCommand(conn, relayRuntimeSelfTestCommand(relayDir, nodePath, nonce), { + timeoutMs: remaining(), + signal + }), + nonce + ) + } catch (error) { + signal?.throwIfAborted() + // A timeout, or a channel that failed to open or dropped, answered nothing about the runtime. + return { + verdict: 'unverifiable', + detail: error instanceof Error ? error.message : String(error), + cause: error + } + } +} + +/** One retry on `unverifiable`, never on an answered verdict. */ +export async function runPinnedRuntimeSelfTest( + conn: SshConnection, + relayDir: string, + nodePath: string, + signal?: AbortSignal, + options: RelayRuntimeSelfTestOptions = {} +): Promise { + const attempts = options.attempts ?? 2 + let verdict: PinnedRuntimeSelfTestVerdict = { + verdict: 'unverifiable', + detail: 'the self-test never ran' + } + for (let attempt = 0; attempt < attempts; attempt++) { + verdict = await runSelfTestOnce(conn, relayDir, nodePath, signal, options) + if (verdict.verdict !== 'unverifiable') { + return verdict + } + // A command whose teardown is unconfirmed may still hold the host; do not stack another. + if (isUnconfirmedSshCommandTermination(verdict.cause)) { + return verdict + } + } + return verdict +} diff --git a/src/main/ssh/ssh-relay-runtime-step-plan.ts b/src/main/ssh/ssh-relay-runtime-step-plan.ts new file mode 100644 index 000000000000..022bf88ed85f --- /dev/null +++ b/src/main/ssh/ssh-relay-runtime-step-plan.ts @@ -0,0 +1,100 @@ +/** Turns one ladder step into a relay plan, or a classified refusal the ladder steps past. */ +import type { SshConnection } from './ssh-connection' +import { planHostNodeAddonRelay, type PrebuiltRelayPlan } from './ssh-relay-host-node-addons' +import { + PinnedRelayFallbackError, + planPinnedNodeRelay, + resolvePinnedRelayTargetFacts +} from './ssh-relay-pinned-node' +import { compatRelayRuntimeFor, type RelayRuntimeStep } from './ssh-relay-runtime-ladder' +import type { RelayRuntimeLadderRun } from './ssh-relay-runtime-resolution' +import type { RemoteHostPlatform } from './ssh-remote-platform' +import type { OrcadDeploymentTargetFacts } from './orcad-deployment-target' + +type StepPlanOptions = { + conn: SshConnection + host: RemoteHostPlatform + baseVersion: string + step: RelayRuntimeStep + run: RelayRuntimeLadderRun + signal?: AbortSignal +} + +/** Resolved once per ladder pass; every rung above legacy keys on the same answer. */ +async function ladderTargetFacts(options: StepPlanOptions): Promise { + const { run } = options + if (run.facts) { + return run.facts + } + const facts = await resolvePinnedRelayTargetFacts(options) + if ('kind' in facts) { + throw new PinnedRelayFallbackError( + facts.fallbackReason ?? 'target_unresolved', + 'the host libc answer was not recognised' + ) + } + run.facts = facts + return facts +} + +/** Undefined for the host-npm path; throws `PinnedRelayFallbackError` when a rung cannot run. */ +export async function planRelayRuntimeStep( + options: StepPlanOptions +): Promise { + const { conn, host, baseVersion, step, run, signal } = options + run.host = host + switch (step) { + case 'legacy': + return undefined + case 'A': { + const facts = await ladderTargetFacts(options) + const plan = await planPinnedNodeRelay({ + conn, + host, + baseVersion, + targetId: run.targetId, + facts, + persistedRefusal: (known) => run.persistedPinnedRefusal(known), + signal + }) + if (plan.kind === 'host-node') { + throw new PinnedRelayFallbackError( + plan.fallbackReason ?? 'artifacts_unavailable', + 'Orca-managed Node cannot run here', + plan.remembered === true + ) + } + return plan + } + case 'B': { + const facts = host.os === 'win32' ? null : await ladderTargetFacts(options) + const compat = facts ? compatRelayRuntimeFor(facts) : null + // Why data-driven: B turns on when a compat runtime is listed, and not before. + throw new PinnedRelayFallbackError( + compat ? 'artifacts_unavailable' : 'runtime_unavailable', + compat + ? `compat runtime ${compat.id} has no deploy path in this build` + : 'no compat runtime serves this host' + ) + } + case 'C': { + if (host.os === 'win32') { + throw new PinnedRelayFallbackError( + 'windows_host_unsupported', + 'Windows hosts have no host-Node addon relay' + ) + } + const plan = await planHostNodeAddonRelay({ + conn, + host, + facts: await ladderTargetFacts(options), + baseVersion, + signal + }) + run.hostNode = plan.hostNode.version + return plan + } + case 'D': + throw new Error('Rung D has no relay to plan') + } +} diff --git a/src/main/ssh/ssh-relay-versioned-install.test.ts b/src/main/ssh/ssh-relay-versioned-install.test.ts index 1778ac1affc3..0dc2f428df95 100644 --- a/src/main/ssh/ssh-relay-versioned-install.test.ts +++ b/src/main/ssh/ssh-relay-versioned-install.test.ts @@ -15,6 +15,11 @@ vi.mock('./ssh-connection-utils', () => ({ shellEscape: (s: string) => `'${s}'` })) +// The previous-build pin has its own tests; here it names a build that is never a candidate. +vi.mock('./remote-install-previous-version', () => ({ + findPreviousRemoteInstall: vi.fn().mockResolvedValue({ state: 'ok', dirName: 'relay-0.1.0+fff' }) +})) + import { existsSync, readFileSync } from 'node:fs' import { readLocalFullVersion, diff --git a/src/main/ssh/ssh-relay-versioned-install.ts b/src/main/ssh/ssh-relay-versioned-install.ts index ee62c3402b28..fc460308c530 100644 --- a/src/main/ssh/ssh-relay-versioned-install.ts +++ b/src/main/ssh/ssh-relay-versioned-install.ts @@ -19,7 +19,6 @@ import { } from './ssh-remote-commands' import { getRemoteHostPlatform, - isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform, type RemotePathFlavor @@ -127,7 +126,7 @@ export async function isRemoteInstallComplete( conn, host, probeRemoteInstallCompleteCommand(host, remoteRelayDir, [ - ...model.requiredArtifacts(isWindowsRemoteHost(host)), + ...model.requiredArtifacts(host), model.installCompleteFilename ]), { signal: options?.signal } diff --git a/src/main/ssh/ssh-remote-commands.ts b/src/main/ssh/ssh-remote-commands.ts index 11e39921fc2f..daa8715e2608 100644 --- a/src/main/ssh/ssh-remote-commands.ts +++ b/src/main/ssh/ssh-remote-commands.ts @@ -201,7 +201,7 @@ export function probeFileExistsCommand(host: RemoteHostPlatform, remotePath: str ) } -type WindowsRelayLivenessOptions = { +export type WindowsRelayLivenessOptions = { nodePath: string pipePaths: string[] } diff --git a/src/main/ssh/ssh-remote-node-addon-resolution.test.ts b/src/main/ssh/ssh-remote-node-addon-resolution.test.ts new file mode 100644 index 000000000000..e721cc8096eb --- /dev/null +++ b/src/main/ssh/ssh-remote-node-addon-resolution.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshConnection } from './ssh-connection' +import { SSH_EXEC_TIMEOUT_CODE } from './ssh-relay-exec-command' + +const execCommandMock = vi.hoisted(() => vi.fn()) + +vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: execCommandMock })) + +// Why: await import() lets vi.mock() register before the module under test is evaluated. +const { resolveRemoteHostNodeForAddons } = await import('./ssh-remote-node-addon-resolution') + +const conn = {} as SshConnection +const facts = (version: string, napi: string): string => + `__ORCA_NODE_VERSION__\n${version}\n__ORCA_NAPI_VERSION__\n${napi}\n` + +describe('resolveRemoteHostNodeForAddons (rung C)', () => { + beforeEach(() => { + execCommandMock.mockReset() + }) + + it('accepts a Node without npm and reports its version and N-API level', async () => { + execCommandMock + .mockResolvedValueOnce('/usr/bin/node\n') + .mockResolvedValueOnce(facts('v18.20.4', '9')) + + await expect(resolveRemoteHostNodeForAddons(conn, 8)).resolves.toEqual({ + nodePath: '/usr/bin/node', + facts: { version: { major: 18, minor: 20 }, napi: 9 } + }) + expect(execCommandMock.mock.calls[1]![1]).not.toContain('npm') + expect(execCommandMock.mock.calls[1]![1]).toContain('-p process.versions.napi') + }) + + it('skips a Node below 18 or below the addons N-API level', async () => { + execCommandMock + .mockResolvedValueOnce('/usr/bin/node\n/opt/n18/bin/node\n/opt/n20/bin/node\n') + .mockResolvedValueOnce(facts('v16.20.2', '8')) + .mockResolvedValueOnce(facts('v18.0.0', '7')) + .mockResolvedValueOnce(facts('v20.11.1', '9')) + + await expect(resolveRemoteHostNodeForAddons(conn, 8)).resolves.toMatchObject({ + nodePath: '/opt/n20/bin/node' + }) + }) + + it('answers null when every probe answered and no Node qualified', async () => { + execCommandMock + .mockResolvedValueOnce('') // path probe: nothing installed + .mockResolvedValueOnce('/bin/bash\n') // $SHELL + .mockRejectedValueOnce(new Error('Command failed (exit 1): ')) // command -v node + + await expect(resolveRemoteHostNodeForAddons(conn, 8)).resolves.toBeNull() + }) + + it('throws rather than answering "no Node" when a probe never answered', async () => { + const lost = Object.assign(new Error('channel lost'), { sshChannelCloseConfirmed: true }) + execCommandMock.mockResolvedValueOnce('/usr/bin/node\n').mockRejectedValueOnce(lost) + + await expect(resolveRemoteHostNodeForAddons(conn, 8)).rejects.toBe(lost) + }) + + it('throws when the login shell probe times out', async () => { + const timeout = Object.assign(new Error('timed out'), { code: SSH_EXEC_TIMEOUT_CODE }) + execCommandMock + .mockResolvedValueOnce('') + .mockResolvedValueOnce('/bin/zsh\n') + .mockRejectedValueOnce(timeout) + + await expect(resolveRemoteHostNodeForAddons(conn, 8)).rejects.toBe(timeout) + }) +}) diff --git a/src/main/ssh/ssh-remote-node-addon-resolution.ts b/src/main/ssh/ssh-remote-node-addon-resolution.ts new file mode 100644 index 000000000000..a482610dfe3a --- /dev/null +++ b/src/main/ssh/ssh-remote-node-addon-resolution.ts @@ -0,0 +1,69 @@ +/** + * Rung C's host Node lookup (design D6): the same candidates as the npm path, but the + * check asks only for Node >= 18 and the addons' N-API level, never npm. + */ +import type { SshConnection } from './ssh-connection' +import type { RemoteNodeResolutionOptions } from './ssh-remote-node-install-guidance' +import { execCommand } from './ssh-relay-deploy-helpers' +import { + commandOptions, + throwIfAborted, + tryResolveViaKnownPaths, + tryResolveViaLoginShell, + type CandidateCheck, + type ProbeOptions +} from './ssh-remote-node-resolution' +import { + buildPosixNodeToolchainProbe, + hostNodeMeetsAddonRequirements, + parseHostNodeAddonFacts, + type HostNodeAddonFacts +} from './ssh-remote-node-toolchain-probe' +import { isSshSessionLimitError } from './ssh-session-limit-error' + +export type RemoteHostNodeForAddons = { nodePath: string; facts: HostNodeAddonFacts } + +/** + * Null means the probes answered and no Node qualified; a probe that never answered + * throws instead, because null steps the runtime ladder down. + */ +export async function resolveRemoteHostNodeForAddons( + conn: SshConnection, + requiredNapi: number, + options?: RemoteNodeResolutionOptions +): Promise { + const strict: ProbeOptions = { ...options, strict: true } + const addonCheck: CandidateCheck = async (candidate) => { + const facts = await probeHostNodeAddonFacts(conn, candidate, strict) + return hostNodeMeetsAddonRequirements(facts, requiredNapi) ? facts : null + } + const found = + (await tryResolveViaKnownPaths(conn, addonCheck, strict)) ?? + (await tryResolveViaLoginShell(conn, addonCheck, strict)) + return found ? { nodePath: found.nodePath, facts: found.result } : null +} + +async function probeHostNodeAddonFacts( + conn: SshConnection, + nodePath: string, + options: ProbeOptions +): Promise { + try { + const output = await execCommand( + conn, + buildPosixNodeToolchainProbe(nodePath, 'addon-only'), + commandOptions({ wrapCommand: true }, options) + ) + return parseHostNodeAddonFacts(output) + } catch (err) { + if (options.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { + throw err + } + throwIfAborted(options) + // Why: the probe ends in `|| true`, so a rejection means the host never answered. + if (options.strict) { + throw err + } + return null + } +} diff --git a/src/main/ssh/ssh-remote-node-resolution.ts b/src/main/ssh/ssh-remote-node-resolution.ts index fd55b6454ac8..a9cf8a7cd47b 100644 --- a/src/main/ssh/ssh-remote-node-resolution.ts +++ b/src/main/ssh/ssh-remote-node-resolution.ts @@ -8,6 +8,7 @@ import { type RemoteNodeResolutionOptions } from './ssh-remote-node-install-guidance' import { execCommand } from './ssh-relay-deploy-helpers' +import { isSshExecTimeout } from './ssh-relay-exec-command' import { buildPosixNodeToolchainProbe, buildWindowsNodeToolchainProbe, @@ -36,29 +37,41 @@ export async function resolveRemoteNodePath( // This doesn't depend on shell startup-file semantics — bash -lc skips // .bashrc and zsh -lc skips .zshrc, but those are exactly the files where // nvm/mise/asdf hooks live. Probing directories directly is deterministic. - const probedPath = await tryResolveViaKnownPaths(conn, options) - if (probedPath) { - return probedPath + const npmCheck: CandidateCheck = async (candidate) => + (await nodeToolchainMeetsRequirements(conn, candidate, options)) || null + const probed = await tryResolveViaKnownPaths(conn, npmCheck, options) + if (probed) { + return probed.nodePath } // Strategy 2 (fallback): ask the user's login shell. Catches custom PATH // setups in ~/.profile / ~/.bash_profile that the probes don't cover. - const loginShellPath = await tryResolveViaLoginShell(conn, options) - if (loginShellPath) { - return loginShellPath + const loginShell = await tryResolveViaLoginShell(conn, npmCheck, options) + if (loginShell) { + return loginShell.nodePath } return throwNodeNotFound(conn, options) } +export type CandidateCheck = (candidate: string) => Promise +type ResolvedCandidate = { nodePath: string; result: T } +/** `strict` rethrows unanswered probes rather than reading them as "no Node here". */ +export type ProbeOptions = RemoteNodeResolutionOptions & { strict?: boolean } + +function isUnansweredExec(err: unknown): boolean { + return isSshExecTimeout(err) || (err instanceof Error && 'sshChannelCloseConfirmed' in err) +} + // Probe the on-disk install directories of every common Node version manager // plus system package-manager locations. Every probe runs unconditionally so // a missing directory prints nothing rather than short-circuiting later // probes. Returns the first candidate with a complete Node/npm toolchain. -async function tryResolveViaKnownPaths( +export async function tryResolveViaKnownPaths( conn: SshConnection, - options?: RemoteNodeResolutionOptions -): Promise { + check: CandidateCheck, + options?: ProbeOptions +): Promise | null> { const script = REMOTE_NODE_PATH_PROBE_SCRIPT try { @@ -70,9 +83,10 @@ async function tryResolveViaKnownPaths( continue } seen.add(candidate) - if (await nodeToolchainMeetsRequirements(conn, candidate, options)) { + const result = await check(candidate) + if (result !== null) { console.log(`[ssh-relay] Found node via path probe: ${candidate}`) - return candidate + return { nodePath: candidate, result } } } } catch (err) { @@ -80,6 +94,10 @@ async function tryResolveViaKnownPaths( throw err } throwIfAborted(options) + // Why: the probe script always exits 0, so any failure here is an unanswered probe. + if (options?.strict) { + throw err + } // Fall through to login shell. } return null @@ -88,10 +106,11 @@ async function tryResolveViaKnownPaths( // Run `command -v node` under the user's login shell, then verify the result // meets the minimum version. Returns null on any failure (shell missing, no // node found, version too old, timeout) so callers fall through to the error. -async function tryResolveViaLoginShell( +export async function tryResolveViaLoginShell( conn: SshConnection, - options?: RemoteNodeResolutionOptions -): Promise { + check: CandidateCheck, + options?: ProbeOptions +): Promise | null> { try { // Why: $SHELL is the user's configured login shell (set by chsh / passwd). // Using it — rather than hardcoding bash — means zsh/fish users whose @@ -117,15 +136,20 @@ async function tryResolveViaLoginShell( return null } - if (await nodeToolchainMeetsRequirements(conn, candidate, options)) { + const result = await check(candidate) + if (result !== null) { console.log(`[ssh-relay] Found node via login shell (${shell}): ${candidate}`) - return candidate + return { nodePath: candidate, result } } } catch (err) { if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { throw err } throwIfAborted(options) + // Why only these: `command -v node` exits non-zero when the shell answered "none". + if (options?.strict && isUnansweredExec(err)) { + throw err + } // Fall through. } return null @@ -260,7 +284,7 @@ function throwWindowsNodeNotFound(options?: RemoteNodeResolutionOptions): never ) } -function throwIfAborted(options?: RemoteNodeResolutionOptions): void { +export function throwIfAborted(options?: RemoteNodeResolutionOptions): void { // Why: strategy fallbacks intentionally swallow probe failures, but a shared // bootstrap abort must stay an AbortError so callers can continue fallback. if (options?.signal?.aborted) { @@ -280,7 +304,7 @@ type RemoteExecOptions = { signal?: AbortSignal } -function commandOptions( +export function commandOptions( base: RemoteExecOptions, options?: RemoteNodeResolutionOptions ): RemoteExecOptions { diff --git a/src/main/ssh/ssh-remote-node-toolchain-probe.test.ts b/src/main/ssh/ssh-remote-node-toolchain-probe.test.ts index 64f1b82d112d..cecf011fffcd 100644 --- a/src/main/ssh/ssh-remote-node-toolchain-probe.test.ts +++ b/src/main/ssh/ssh-remote-node-toolchain-probe.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest' import { buildPosixNodeToolchainProbe, buildWindowsNodeToolchainProbe, - nodeToolchainVersionsMeetRequirements + hostNodeMeetsAddonRequirements, + nodeToolchainVersionsMeetRequirements, + parseHostNodeAddonFacts } from './ssh-remote-node-toolchain-probe' describe('remote Node/npm toolchain probe', () => { @@ -46,4 +48,28 @@ describe('remote Node/npm toolchain probe', () => { expect(nodeToolchainVersionsMeetRequirements('v18.0.0\n')).toBe(true) expect(nodeToolchainVersionsMeetRequirements('v16.20.2\n')).toBe(false) }) + + it('probes Node and its N-API level without npm in addon-only mode', () => { + expect(buildPosixNodeToolchainProbe('/opt/node/bin/node', 'addon-only')).toBe( + "printf '%s\\n' '__ORCA_NODE_VERSION__' && '/opt/node/bin/node' --version && " + + "printf '%s\\n' '__ORCA_NAPI_VERSION__' && '/opt/node/bin/node' -p process.versions.napi || true" + ) + }) + + it('parses addon facts and gates on the Node 18 floor and N-API level', () => { + const facts = parseHostNodeAddonFacts( + 'motd\n__ORCA_NODE_VERSION__\nv18.19.1\n__ORCA_NAPI_VERSION__\n9\n' + ) + expect(facts).toEqual({ version: { major: 18, minor: 19 }, napi: 9 }) + expect(hostNodeMeetsAddonRequirements(facts, 8)).toBe(true) + expect(hostNodeMeetsAddonRequirements(facts, 10)).toBe(false) + expect( + hostNodeMeetsAddonRequirements( + parseHostNodeAddonFacts('__ORCA_NODE_VERSION__\nv16.20.2\n__ORCA_NAPI_VERSION__\n8\n'), + 8 + ) + ).toBe(false) + // A Node that failed to run prints no version: not a candidate. + expect(parseHostNodeAddonFacts('__ORCA_NODE_VERSION__\n__ORCA_NAPI_VERSION__\n')).toBeNull() + }) }) diff --git a/src/main/ssh/ssh-remote-node-toolchain-probe.ts b/src/main/ssh/ssh-remote-node-toolchain-probe.ts index d6d7f1d7a069..1882b2f6760f 100644 --- a/src/main/ssh/ssh-remote-node-toolchain-probe.ts +++ b/src/main/ssh/ssh-remote-node-toolchain-probe.ts @@ -2,15 +2,36 @@ import { posix as posixPath } from 'node:path' import { shellEscape } from './ssh-connection-utils' import { powerShellLiteral } from './ssh-remote-powershell' -const MIN_NODE_MAJOR = 18 +/** The host-Node floor for both the npm path and rung C (design D6). */ +export const MIN_HOST_NODE_MAJOR = 18 const NODE_VERSION_MARKER = '__ORCA_NODE_VERSION__' const NPM_VERSION_MARKER = '__ORCA_NPM_VERSION__' +const NAPI_VERSION_MARKER = '__ORCA_NAPI_VERSION__' -export function buildPosixNodeToolchainProbe(nodePath: string): string { +/** + * `npm` installs native deps on the host; `addon-only` loads Orca's prebuilt N-API addons, + * so it needs a Node and its N-API level but never npm (design D6 rung C). + */ +export type NodeToolchainRequirement = 'npm' | 'addon-only' + +export function buildPosixNodeToolchainProbe( + nodePath: string, + requirement: NodeToolchainRequirement = 'npm' +): string { const nodeBinDir = posixPath.dirname(nodePath) + const node = shellEscape(nodePath) + if (requirement === 'addon-only') { + // Why `|| true`: a Node that cannot run is an answer ("not this one"), not a lost channel. + return `${[ + `printf '%s\\n' '${NODE_VERSION_MARKER}'`, + `${node} --version`, + `printf '%s\\n' '${NAPI_VERSION_MARKER}'`, + `${node} -p process.versions.napi` + ].join(' && ')} || true` + } return [ `printf '%s\\n' '${NODE_VERSION_MARKER}'`, - `${shellEscape(nodePath)} --version`, + `${node} --version`, `printf '%s\\n' '${NPM_VERSION_MARKER}'`, // Why: deploy prepends nodeBinDir before running bare npm; requiring a // colocated executable rejects valid split layouts (#9165). @@ -35,29 +56,60 @@ export function buildWindowsNodeToolchainProbe(nodePath: string): string { } export function nodeToolchainVersionsMeetRequirements(versionOutput: string): boolean { - const nodeMajor = markedVersionMajor(versionOutput, NODE_VERSION_MARKER) - const npmMajor = markedVersionMajor(versionOutput, NPM_VERSION_MARKER) + const nodeMajor = markedVersion(versionOutput, NODE_VERSION_MARKER)?.major ?? null + const npmMajor = markedVersion(versionOutput, NPM_VERSION_MARKER)?.major ?? null if (versionOutput.includes(NODE_VERSION_MARKER)) { - return nodeMajor !== null && nodeMajor >= MIN_NODE_MAJOR && npmMajor !== null + return nodeMajor !== null && nodeMajor >= MIN_HOST_NODE_MAJOR && npmMajor !== null } // Why: existing proxy integrations and tests may return only the Node // version even though production probes now emit both version markers. const legacyMatch = versionOutput.trim().match(/^v?(\d+)/) - return legacyMatch ? Number.parseInt(legacyMatch[1]!, 10) >= MIN_NODE_MAJOR : false + return legacyMatch ? Number.parseInt(legacyMatch[1]!, 10) >= MIN_HOST_NODE_MAJOR : false +} + +export type HostNodeVersion = { major: number; minor: number } + +export type HostNodeAddonFacts = { version: HostNodeVersion; napi: number } + +/** The addon-only probe's answer, or null when the Node did not print both facts. */ +export function parseHostNodeAddonFacts(output: string): HostNodeAddonFacts | null { + const version = markedVersion(output, NODE_VERSION_MARKER) + const napi = markedInteger(output, NAPI_VERSION_MARKER) + return version && napi !== null ? { version, napi } : null +} + +export function hostNodeMeetsAddonRequirements( + facts: HostNodeAddonFacts | null, + requiredNapi: number +): boolean { + return facts !== null && facts.version.major >= MIN_HOST_NODE_MAJOR && facts.napi >= requiredNapi } -function markedVersionMajor(output: string, marker: string): number | null { +function linesAfterMarker(output: string, marker: string): string[] | null { const lines = output.split(/\r?\n/) const markerIndex = lines.indexOf(marker) if (markerIndex === -1) { return null } - for (const line of lines.slice(markerIndex + 1)) { - if (line.startsWith('__ORCA_')) { - return null + const rest = lines.slice(markerIndex + 1) + const next = rest.findIndex((line) => line.startsWith('__ORCA_')) + return next === -1 ? rest : rest.slice(0, next) +} + +function markedVersion(output: string, marker: string): HostNodeVersion | null { + for (const line of linesAfterMarker(output, marker) ?? []) { + const match = line.trim().match(/^v?(\d+)(?:\.(\d+))(?:\.\d+)?(?:[-+].*)?$/) + if (match) { + return { major: Number.parseInt(match[1]!, 10), minor: Number.parseInt(match[2]!, 10) } } - const match = line.trim().match(/^v?(\d+)(?:\.\d+){1,2}(?:[-+].*)?$/) + } + return null +} + +function markedInteger(output: string, marker: string): number | null { + for (const line of linesAfterMarker(output, marker) ?? []) { + const match = line.trim().match(/^(\d+)$/) if (match) { return Number.parseInt(match[1]!, 10) } diff --git a/src/main/ssh/ssh-remote-orca-cli.test.ts b/src/main/ssh/ssh-remote-orca-cli.test.ts index 637a0c55ce3e..90e7830099d8 100644 --- a/src/main/ssh/ssh-remote-orca-cli.test.ts +++ b/src/main/ssh/ssh-remote-orca-cli.test.ts @@ -376,7 +376,7 @@ describe('runRemoteOrcaCli', () => { } }) - it('carries the Dispatch capability through the SSH envelope', async () => { + it("still accepts an older host's --dispatch-capability through the SSH bridge", async () => { const db = new OrchestrationDb(':memory:') const runtime = new OrcaRuntimeService() runtime.setOrchestrationDb(db) @@ -396,7 +396,7 @@ describe('runRemoteOrcaCli', () => { taskId: task.id, startOptions: {} }) - const capability = db.prepareStartingWorkerAuthority({ + db.prepareStartingWorkerAuthority({ dispatchId: started.dispatch.id, handle: 'term_ssh', paneKey: 'tab_ssh:leaf_ssh', @@ -425,7 +425,7 @@ describe('runRemoteOrcaCli', () => { '--outcome', 'succeeded', '--dispatch-capability', - capability, + 'dcap_from_an_old_host', '--json' ], cwd: '/home/alice/repo', diff --git a/src/main/ssh/ssh-remote-orca-cli.ts b/src/main/ssh/ssh-remote-orca-cli.ts index 6f476fa0d84d..3913a65cb9f5 100644 --- a/src/main/ssh/ssh-remote-orca-cli.ts +++ b/src/main/ssh/ssh-remote-orca-cli.ts @@ -220,10 +220,7 @@ async function dispatchRemoteCli( // authority as the full host CLI passthrough. senderPaneKey: env.ORCA_PANE_KEY || undefined }, - { - ...compatibilityEnvelope, - orchestrationCapability: optionalRemoteCliString(parsed.flags, 'dispatch-capability') - } + compatibilityEnvelope ) } case 'orchestration check': @@ -263,10 +260,7 @@ async function dispatchRemoteCli( run: optionalRemoteCliString(parsed.flags, 'run'), compatibilityCliCommand: 'orca' }, - { - ...compatibilityEnvelope, - orchestrationCapability: optionalRemoteCliString(parsed.flags, 'dispatch-capability') - } + compatibilityEnvelope ) case 'orchestration reply': return await call( @@ -310,7 +304,6 @@ async function call( authToken: 'remote-cli', method, params, - orchestrationCapability: envelope?.orchestrationCapability, orchestrationContractVersion: method.startsWith('orchestration.') ? ORCHESTRATION_CONTRACT_VERSION : undefined, diff --git a/src/main/ssh/ssh-remote-orchestration-compatibility.test.ts b/src/main/ssh/ssh-remote-orchestration-compatibility.test.ts index e84b483b5b1c..b321dfba8418 100644 --- a/src/main/ssh/ssh-remote-orchestration-compatibility.test.ts +++ b/src/main/ssh/ssh-remote-orchestration-compatibility.test.ts @@ -483,31 +483,4 @@ describe('legacy SSH orchestration fallback', () => { db.close() } }) - - it.each([ - ['check', ['orchestration', 'check', '--terminal', COORDINATOR_HANDLE]], - ['ask', ['orchestration', 'ask', '--from', WORKER_HANDLE, '--question', 'continue?']] - ])('refuses a non-UUID --retry-request on orchestration %s', async (_label, commandArgv) => { - const { db, runtime } = createLegacyRuntime() - - try { - const result = await runRemoteOrcaCli( - runtime, - { - argv: [...commandArgv, '--retry-request', 'ssh-check-1', '--json'], - cwd: '/home/alice/repo', - env: WORKER_ENV, - runtimeAuthority: RUNTIME_AUTHORITY - }, - LEGACY_FALLBACK_OPTIONS - ) - - expect(result.exitCode).toBe(1) - expect(JSON.parse(result.stdout)).toMatchObject({ - error: { code: 'invalid_argument', message: expect.stringContaining('must be the UUID') } - }) - } finally { - db.close() - } - }) }) diff --git a/src/main/ssh/ssh-remote-runtime-telemetry.test.ts b/src/main/ssh/ssh-remote-runtime-telemetry.test.ts new file mode 100644 index 000000000000..9507f003ebce --- /dev/null +++ b/src/main/ssh/ssh-remote-runtime-telemetry.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { eventSchemas } from '../../shared/telemetry-event-registry' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) + +const { track } = await import('../telemetry/client') +const { + durationBucket, + glibcMinorBucket, + resetSshRemoteRuntimeTelemetryForTests, + sshRemoteRuntimeResolvedProps, + trackSshRemoteRuntimeResolved +} = await import('./ssh-remote-runtime-telemetry') + +const linux = getRemoteHostPlatform('linux-x64') +const base = { + rung: 'C' as const, + host: linux, + facts: { target: 'linux-x64-glibc' as const, glibc: { major: 2, minor: 28 } }, + firstRefusal: 'illegal_instruction', + selfTest: 'passed' as const, + runtimeTransfer: 'uploaded' as const, + hostNode: { major: 18, minor: 19 }, + durationMs: 12_000 +} + +describe('ssh_remote_runtime_resolved', () => { + beforeEach(() => { + vi.mocked(track).mockReset() + resetSshRemoteRuntimeTelemetryForTests() + }) + + it('maps a rung C connect onto enum-only props the strict schema accepts', () => { + const props = sshRemoteRuntimeResolvedProps(base) + expect(props).toEqual({ + rung: 'c', + host_os: 'linux', + host_arch: 'x64', + host_libc: 'glibc', + glibc_minor: '28', + first_refusal: 'illegal_instruction', + self_test: 'passed', + runtime_transfer: 'uploaded', + host_node_major: '18', + duration_bucket: '5s_15s' + }) + expect(eventSchemas.ssh_remote_runtime_resolved.safeParse(props).success).toBe(true) + }) + + it('rejects any free-form field, so a hostname or path can never ride along', () => { + const props = { ...sshRemoteRuntimeResolvedProps(base), host: 'build-01.corp' } + expect(eventSchemas.ssh_remote_runtime_resolved.safeParse(props).success).toBe(false) + }) + + it('buckets glibc minors, durations and unknown refusals', () => { + expect(glibcMinorBucket(null)).toBe('none') + expect(glibcMinorBucket({ target: 'linux-x64-glibc', glibc: { major: 2, minor: 12 } })).toBe( + 'below_17' + ) + expect(glibcMinorBucket({ target: 'linux-x64-glibc', glibc: { major: 2, minor: 50 } })).toBe( + 'above_42' + ) + expect(durationBucket(1_000)).toBe('lt_5s') + expect(durationBucket(90_000)).toBe('gte_60s') + expect( + sshRemoteRuntimeResolvedProps({ ...base, rung: 'A', firstRefusal: 'from-a-newer-build' }) + ).toMatchObject({ rung: 'a', first_refusal: 'none' }) + expect( + sshRemoteRuntimeResolvedProps({ ...base, rung: 'A', hostNode: null }) + ).not.toHaveProperty('host_node_major') + }) + + it('reports each host once per app session', () => { + trackSshRemoteRuntimeResolved('target-1', base) + trackSshRemoteRuntimeResolved('target-1', { ...base, rung: 'D' }) + trackSshRemoteRuntimeResolved('target-2', base) + expect(track).toHaveBeenCalledTimes(2) + expect(JSON.stringify(vi.mocked(track).mock.calls)).not.toContain('target-') + }) +}) diff --git a/src/main/ssh/ssh-remote-runtime-telemetry.ts b/src/main/ssh/ssh-remote-runtime-telemetry.ts new file mode 100644 index 000000000000..2f81062ca0f5 --- /dev/null +++ b/src/main/ssh/ssh-remote-runtime-telemetry.ts @@ -0,0 +1,114 @@ +/** + * `ssh_remote_runtime_resolved` (design D6): once per host per app session, enum-only. + * The dedupe key is the local target id, which never leaves this process. + */ +import type { SshRemoteRuntimeRung } from '../../shared/ssh-types' +import type { EventProps } from '../../shared/telemetry-events' +import { + SSH_RUNTIME_GLIBC_MINOR_VALUES, + SSH_RUNTIME_HOST_NODE_MAJOR_VALUES, + SSH_RUNTIME_REFUSAL_VALUES +} from '../../shared/telemetry-ssh-runtime-event-schemas' +import { track } from '../telemetry/client' +import type { OrcadDeploymentTargetFacts } from './orcad-deployment-target' +import type { RemoteHostPlatform } from './ssh-remote-platform' +import type { HostNodeVersion } from './ssh-remote-node-toolchain-probe' + +type Props = EventProps<'ssh_remote_runtime_resolved'> + +export type SshRemoteRuntimeResolvedFacts = { + rung: SshRemoteRuntimeRung + host: RemoteHostPlatform + facts: OrcadDeploymentTargetFacts | null + firstRefusal: string | null + selfTest: Props['self_test'] + runtimeTransfer: Props['runtime_transfer'] + hostNode: HostNodeVersion | null + durationMs: number +} + +const reportedHosts = new Set() + +const RUNG_VALUES: Record = { + A: 'a', + B: 'b', + C: 'c', + D: 'd', + legacy: 'legacy' +} + +function pick(values: readonly T[], value: string, fallback: T): T { + return values.find((candidate) => candidate === value) ?? fallback +} + +export function glibcMinorBucket(facts: OrcadDeploymentTargetFacts | null): Props['glibc_minor'] { + const glibc = facts?.glibc + if (!glibc || glibc.major !== 2) { + return 'none' + } + if (glibc.minor < 17) { + return 'below_17' + } + return pick(SSH_RUNTIME_GLIBC_MINOR_VALUES, String(glibc.minor), 'above_42') +} + +export function durationBucket(durationMs: number): Props['duration_bucket'] { + if (durationMs < 5_000) { + return 'lt_5s' + } + if (durationMs < 15_000) { + return '5s_15s' + } + return durationMs < 60_000 ? '15s_60s' : 'gte_60s' +} + +function hostLibc( + facts: OrcadDeploymentTargetFacts | null, + host: RemoteHostPlatform +): Props['host_libc'] { + if (host.os !== 'linux') { + return 'none' + } + if (!facts) { + return 'unknown' + } + return facts.target.endsWith('-musl') ? 'musl' : 'glibc' +} + +export function sshRemoteRuntimeResolvedProps(input: SshRemoteRuntimeResolvedFacts): Props { + const hostNodeMajor = input.hostNode + ? pick(SSH_RUNTIME_HOST_NODE_MAJOR_VALUES, String(input.hostNode.major), 'above_30') + : null + return { + rung: RUNG_VALUES[input.rung], + host_os: input.host.os, + host_arch: input.host.arch, + host_libc: hostLibc(input.facts, input.host), + glibc_minor: glibcMinorBucket(input.facts), + first_refusal: pick(SSH_RUNTIME_REFUSAL_VALUES, input.firstRefusal ?? 'none', 'none'), + self_test: input.selfTest, + runtime_transfer: input.runtimeTransfer, + ...(hostNodeMajor ? { host_node_major: hostNodeMajor } : {}), + duration_bucket: durationBucket(input.durationMs) + } +} + +export function trackSshRemoteRuntimeResolved( + hostKey: string, + input: SshRemoteRuntimeResolvedFacts +): void { + if (reportedHosts.has(hostKey)) { + return + } + reportedHosts.add(hostKey) + try { + // track() applies the existing consent gate and validates against the strict schema. + track('ssh_remote_runtime_resolved', sshRemoteRuntimeResolvedProps(input)) + } catch { + // Telemetry never costs a connect. + } +} + +export function resetSshRemoteRuntimeTelemetryForTests(): void { + reportedHosts.clear() +} diff --git a/src/main/ssh/ssh-target-readoption.test.ts b/src/main/ssh/ssh-target-readoption.test.ts index c8d8cd24a18f..231a17397ce0 100644 --- a/src/main/ssh/ssh-target-readoption.test.ts +++ b/src/main/ssh/ssh-target-readoption.test.ts @@ -89,18 +89,6 @@ describe('readoptOrphanedWorkspacesForTarget', () => { expect(readoptions).toHaveLength(1) }) - it('does not match alias against a different host tuple', () => { - // Different alias AND different tuple => no match. - const fake = makeFakeStore([ - tombstone({ configHost: 'prod', host: 'prod.example.com', username: 'root' }) - ]) - const readoptions = readoptOrphanedWorkspacesForTarget( - fake.store, - makeTarget({ configHost: 'devbox', host: 'dev.example.com', username: 'tim' }) - ) - expect(readoptions).toEqual([]) - }) - it('is a no-op when there are no tombstones', () => { const fake = makeFakeStore([]) expect(readoptOrphanedWorkspacesForTarget(fake.store, makeTarget())).toEqual([]) diff --git a/src/main/ssh/system-ssh-file-binary-transfer.ts b/src/main/ssh/system-ssh-file-binary-transfer.ts index 149d10dfbd36..662d15482318 100644 --- a/src/main/ssh/system-ssh-file-binary-transfer.ts +++ b/src/main/ssh/system-ssh-file-binary-transfer.ts @@ -237,7 +237,7 @@ async function withTemporaryLocalFile( } } -function makePosixWriteFileCommand( +export function makePosixWriteFileCommand( remotePath: string, options?: { append?: boolean; exclusive?: boolean } ): string { diff --git a/src/main/ssh/system-ssh-file-transfer.ts b/src/main/ssh/system-ssh-file-transfer.ts index d57579043567..ada0f85a013c 100644 --- a/src/main/ssh/system-ssh-file-transfer.ts +++ b/src/main/ssh/system-ssh-file-transfer.ts @@ -119,7 +119,7 @@ async function uploadDirectoryViaSystemSshWindows( if (!hostPlatform) { throw new Error('Windows system SSH upload requires a remote host platform') } - const plan = await collectWindowsUploadPlan(localDir, remoteDir, hostPlatform, options.signal) + const plan = await collectLocalUploadPlan(localDir, remoteDir, hostPlatform, options.signal) await createWindowsUploadDirectories(target, plan.directories, options) for (const file of plan.files) { throwIfAborted(options.signal) @@ -129,7 +129,7 @@ async function uploadDirectoryViaSystemSshWindows( } } -type WindowsUploadPlan = { +export type LocalUploadPlan = { directories: string[] files: { localPath: string; remotePath: string }[] } @@ -141,13 +141,13 @@ type WindowsUploadPlan = { * about a directory upload requires one frame: the plan carries paths only, and the bytes go per * file, in writes bounded by WINDOWS_STDIN_WRITE_CHUNK_BYTES. */ -async function collectWindowsUploadPlan( +export async function collectLocalUploadPlan( localDir: string, remoteDir: string, hostPlatform: RemoteHostPlatform, signal: AbortSignal | undefined, - plan: WindowsUploadPlan = { directories: [], files: [] } -): Promise { + plan: LocalUploadPlan = { directories: [], files: [] } +): Promise { plan.directories.push(remoteDir) const dirEntries = await readdir(localDir, { withFileTypes: true }) for (const entry of dirEntries) { @@ -159,7 +159,7 @@ async function collectWindowsUploadPlan( continue } if (statResult.isDirectory()) { - await collectWindowsUploadPlan(localPath, remotePath, hostPlatform, signal, plan) + await collectLocalUploadPlan(localPath, remotePath, hostPlatform, signal, plan) continue } plan.files.push({ localPath, remotePath }) diff --git a/src/main/startup/codex-launch-during-real-home-grant.test.ts b/src/main/startup/codex-launch-during-real-home-grant.test.ts new file mode 100644 index 000000000000..ea10e8bd1d1b --- /dev/null +++ b/src/main/startup/codex-launch-during-real-home-grant.test.ts @@ -0,0 +1,513 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import type * as Os from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { HookDefinition } from '../agent-hooks/installer-utils' +import type { TuiAgent } from '../../shared/tui-agent' +import type { + CodexHookTrustGrantRequest, + CodexHookTrustGrantSessionResult +} from '../codex/codex-app-server-client' +import { CodexAppServerTimeoutError } from '../codex/codex-app-server-session' +import { isCodexManagedCommand, setupCodexHookHomes } from '../codex/hook-service-test-harness' + +// Why this file (QA case 4, full launch path): Codex's approval of the real-home +// entry runs in the background. A launch during it goes to the managed home and +// waits only on that home's own setup, which is bounded by its inline approval. +// A resume has no other home, so it waits for that one approval to settle. + +const { getPathMock, homedirMock, resolveCodexCommandMock, settings } = vi.hoisted(() => { + const disabledTuiAgents: TuiAgent[] = [] + return { + getPathMock: vi.fn<(name: string) => string>(), + homedirMock: vi.fn<() => string>(), + resolveCodexCommandMock: vi.fn<() => string>(), + settings: { agentStatusHooksEnabled: true, disabledTuiAgents } + } +}) + +vi.mock('electron', () => ({ app: { getPath: getPathMock } })) +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, homedir: homedirMock } +}) +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, homedir: homedirMock } +}) +vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock })) +vi.mock('../wsl', () => ({ getDefaultWslDistro: () => 'Ubuntu' })) +vi.mock('../codex/codex-legacy-session-resume', () => ({ + prepareLegacySharedCodexSessionResume: async () => ({ useRealCodexHome: false }) +})) +// Why: provenance is verified elsewhere; this resumes a session found in the real home. +vi.mock('../codex/codex-session-resume-preparation', async () => { + const { getSystemCodexHomePath } = await import('../codex/codex-home-paths') + return { + prepareCodexSessionResume: async (args: { + resolveVerifiedResumeHome: (source: { + homePath: string + transcriptPath: string + }) => Promise + }) => { + const homePath = getSystemCodexHomePath() + const codexHomePath = await args.resolveVerifiedResumeHome({ + homePath, + transcriptPath: join(homePath, 'sessions', 'abc.jsonl') + }) + return { outcome: 'resume', codexHomePath, sessionId: 'abc' } + } + } +}) +// Why: the real predicate, without loading every agent's hook service. +vi.mock( + '../agent-hooks/managed-agent-hook-controls', + async () => await import('../../shared/agent-status-hooks-setting') +) +vi.mock('./main-process-state', async () => { + const { isRealHomeCodexHookLaneUsable } = await import('../codex/codex-real-home-hook-install') + const { getOrcaManagedCodexHomePath } = await import('../codex/codex-home-paths') + return { + mainProcessState: { + codexRuntimeHome: { + isHostSystemDefaultRealHomeSelected: () => true, + isHostSystemDefaultRealHome: () => isRealHomeCodexHookLaneUsable(), + getHostCodexHomePathsForSessionDiscovery: () => [], + resolveSelectedHostAccountCodexHomePathForResume: () => null, + // Why: the runtime home service's lane gate, reduced to its verdict. + prepareForCodexLaunchAsync: async () => + isRealHomeCodexHookLaneUsable() ? null : getOrcaManagedCodexHomePath() + }, + store: { getSettings: () => settings } + } + } +}) + +const { CODEX_BACKGROUND_TRUST_GRANT_TIMEOUT_MS, _internals: grantInternals } = + await import('../codex/codex-hook-trust-grant') +const { + _internals: realHomeInternals, + ensureRealHomeCodexHookState, + isRealHomeCodexHookLaneUsable, + removeRealHomeCodexHookForOptOut, + setRealHomeCodexHooksEnabledReader +} = await import('../codex/codex-real-home-hook-install') +const { isAgentStatusHooksEnabledForAgent } = + await import('../../shared/agent-status-hooks-setting') +const { getOrcaManagedCodexHomePath } = await import('../codex/codex-home-paths') +const { prepareCodexRuntimeHomeForLaunch } = await import('./codex-launch-preparation') +const { applyAgentWorkspaceTrust } = await import('../agent-workspace-trust') +const { prepareCodexSessionResumeForLaunch } = await import('./codex-session-resume-launch') +const { + computeTrustedHash, + normalizeHookTrustKeyForLookup, + parseTrustKey, + readHookTrustEntries, + upsertHookTrustEntries +} = await import('../codex/config-toml-trust') +const { createCodexHookTrustEntry } = await import('../codex/codex-hook-identity') +const { readOrcaEntryTrust } = await import('../codex/codex-real-home-entry-trust') + +const homes = setupCodexHookHomes(homedirMock, getPathMock) + +function settlesWithin(promise: Promise, ms: number): Promise { + let timer: ReturnType | undefined + return Promise.race([ + promise.then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), ms) + }) + ]).finally(() => clearTimeout(timer)) +} + +/** What codex app-server does once it answers: approves every expected entry. */ +function approveAll(request: CodexHookTrustGrantRequest): CodexHookTrustGrantSessionResult { + const entries = request.expectedTrustKeys.map((key) => { + const entry = { ...parseTrustKey(key)!, command: request.managedCommand, timeoutSec: 10 } + return { key, entry, trustedHash: computeTrustedHash(entry) } + }) + upsertHookTrustEntries( + join(request.hooksListCwd, 'config.toml'), + entries.map(({ entry, trustedHash }) => ({ ...entry, trustedHash })) + ) + return { + outcome: 'granted', + wroteTrust: true, + entries: entries.map(({ key, trustedHash }) => ({ + key, + normalizedKey: normalizeHookTrustKeyForLookup(key), + trustedHash + })) + } +} + +function isRealHomeSession(request: CodexHookTrustGrantRequest): boolean { + return request.invocation.envToDelete?.includes('CODEX_HOME') === true +} + +function workspaceDirs(): string[] { + return ['one', 'two'].map((name) => { + const path = join(homes.tmpHome, name) + mkdirSync(path, { recursive: true }) + return path + }) +} + +/** How Codex will treat each Orca entry in the real ~/.codex/hooks.json. */ +function realHomeOrcaEntryTrust(): string[] { + const hooksPath = join(homes.tmpHome, '.codex', 'hooks.json') + const hooks: Record = JSON.parse(readFileSync(hooksPath, 'utf-8')).hooks + const trust = readHookTrustEntries(join(homes.tmpHome, '.codex', 'config.toml')) + return Object.entries(hooks).flatMap(([eventName, definitions]) => + definitions.flatMap((definition, groupIndex) => + (definition.hooks ?? []).flatMap((hook, handlerIndex) => { + if (!isCodexManagedCommand(hook.command)) { + return [] + } + const entry = createCodexHookTrustEntry( + hooksPath, + eventName, + groupIndex, + handlerIndex, + definition, + hook + ) + return [entry ? readOrcaEntryTrust(entry, trust) : 'untrusted'] + }) + ) + ) +} + +function resume(): Promise { + return prepareCodexSessionResumeForLaunch({ + providerSession: { key: 'session_id', id: 'abc' }, + target: { runtime: 'host' } + }) +} + +/** A pane launch: home prep, then the spawn hook's project-trust write, as the spawn builder runs them. */ +async function launch(workspacePath: string): Promise { + const home = await prepareCodexRuntimeHomeForLaunch() + await applyAgentWorkspaceTrust('codex', workspacePath, { + env: undefined, + claudeAuth: null, + wslDistro: null, + connectionId: null + }) + return home +} + +/** Real-home sessions time out at their own limit, as on a cold host; managed homes approve. */ +function timeOutRealHomeSessions(): { outcomes: string[] } { + const outcomes: string[] = [] + const record = { outcomes } + grantInternals.setGrantSessionRunner(async (request: CodexHookTrustGrantRequest) => { + if (!isRealHomeSession(request)) { + return approveAll(request) + } + if (record.outcomes.length > 0) { + // Why: a later session lists what hooks.json holds now, as Codex does. + const listed = realHomeOrcaEntryTrust().length + const outcome = listed === request.expectedTrustKeys.length ? 'granted' : 'list-mismatch' + record.outcomes.push(outcome) + return outcome === 'granted' + ? approveAll(request) + : { outcome: 'verify-failed', reason: 'list mismatch', reasonClass: 'list-mismatch' } + } + return new Promise((_resolve, reject) => + setTimeout(() => { + record.outcomes.push('timeout') + reject(new CodexAppServerTimeoutError('codex app-server session timed out')) + }, request.invocation.timeoutMs) + ) + }) + return record +} + +/** Starts resumes together and records when each would spawn, from now. */ +function resumeTogether(count: number): { spawnedAt: number[]; done: Promise } { + const startedAt = Date.now() + const spawnedAt: number[] = [] + const done = Promise.all( + Array.from({ length: count }, (_unused, index) => + resume().then(() => { + spawnedAt[index] = Date.now() - startedAt + }) + ) + ) + return { spawnedAt, done } +} + +afterEach(() => { + vi.useRealTimers() +}) + +beforeEach(() => { + realHomeInternals.resetForTesting('pending') + settings.agentStatusHooksEnabled = true + setRealHomeCodexHooksEnabledReader(() => isAgentStatusHooksEnabledForAgent(settings, 'codex')) + resolveCodexCommandMock.mockReturnValue(process.execPath) + mkdirSync(join(homes.tmpHome, '.codex'), { recursive: true }) + writeFileSync(join(homes.tmpHome, '.codex', 'hooks.json'), '{"hooks":{}}\n') + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) +}) + +describe('a Codex launch while the real-home approval hangs', () => { + it('settles on the managed home with its hooks and the project trust written', async () => { + let release: () => void = () => {} + const hung = new Promise((resolve) => { + release = resolve + }) + let realHomeSessions = 0 + let managedSessions = 0 + grantInternals.setGrantSessionRunner(async (request: CodexHookTrustGrantRequest) => { + if (isRealHomeSession(request)) { + realHomeSessions += 1 + await hung + throw new Error('codex app-server exited before completing the session') + } + managedSessions += 1 + return approveAll(request) + }) + const workspaces = workspaceDirs() + + try { + const first = launch(workspaces[0]) + expect(await settlesWithin(first, 2_000)).toBe(true) + expect(await first).toBe(getOrcaManagedCodexHomePath()) + const second = launch(workspaces[1]) + expect(await settlesWithin(second, 2_000)).toBe(true) + expect(await second).toBe(getOrcaManagedCodexHomePath()) + expect(realHomeSessions).toBe(1) + // Why: the second launch finds the managed home's approval in its ledger. + expect(managedSessions).toBe(1) + + const managedHooks = readFileSync(join(getOrcaManagedCodexHomePath(), 'hooks.json'), 'utf-8') + expect(managedHooks).toContain('codex-hook') + const systemConfig = readFileSync(join(homes.tmpHome, '.codex', 'config.toml'), 'utf-8') + for (const workspace of workspaces) { + expect(systemConfig).toContain(workspace) + } + expect(systemConfig.match(/trust_level = "trusted"/g)).toHaveLength(2) + } finally { + release() + await realHomeInternals.settledVerdictForTesting() + } + }) + + it("waits up to the managed home's own 10 s approval when that home is cold too", async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + let release: () => void = () => {} + const hung = new Promise((resolve) => { + release = resolve + }) + const managedTimeouts: number[] = [] + grantInternals.setGrantSessionRunner(async (request: CodexHookTrustGrantRequest) => { + if (isRealHomeSession(request)) { + await hung + throw new Error('codex app-server exited before completing the session') + } + managedTimeouts.push(request.invocation.timeoutMs) + // Why: as the real session does, a cold app-server fails at its own deadline. + return new Promise((_resolve, reject) => + setTimeout( + () => reject(new CodexAppServerTimeoutError('codex app-server session timed out')), + request.invocation.timeoutMs + ) + ) + }) + const workspaces = workspaceDirs() + + try { + let firstSettled = false + const first = launch(workspaces[0]).finally(() => { + firstSettled = true + }) + await vi.advanceTimersByTimeAsync(9_999) + expect(firstSettled).toBe(false) + await vi.advanceTimersByTimeAsync(1) + expect(firstSettled).toBe(true) + expect(await first).toBe(getOrcaManagedCodexHomePath()) + expect(managedTimeouts).toEqual([10_000]) + + // Why: the managed home's failed approval cools down for 5 minutes, so the next launch does not wait. + let secondSettled = false + const second = launch(workspaces[1]).finally(() => { + secondSettled = true + }) + await vi.advanceTimersByTimeAsync(0) + expect(secondSettled).toBe(true) + expect(await second).toBe(getOrcaManagedCodexHomePath()) + expect(managedTimeouts).toEqual([10_000]) + } finally { + release() + // Why: a failed assertion must not leave a managed session holding the lane. + await vi.advanceTimersByTimeAsync(60_000) + await realHomeInternals.settledVerdictForTesting() + } + }) +}) + +describe('a Codex resume into the real ~/.codex while its approval runs', () => { + it('waits for a warm approval, and spawns with the entries approved', async () => { + grantInternals.setGrantSessionRunner(async (request: CodexHookTrustGrantRequest) => { + await new Promise((resolve) => setTimeout(resolve, 100)) + return approveAll(request) + }) + let trustAtSpawn: string[] = [] + const resumed = resume().then(() => { + trustAtSpawn = realHomeOrcaEntryTrust() + }) + + expect(await settlesWithin(resumed, 50)).toBe(false) + expect(await settlesWithin(resumed, 2_000)).toBe(true) + expect(trustAtSpawn.length).toBeGreaterThan(0) + expect(trustAtSpawn.every((state) => state === 'trusted')).toBe(true) + }) + + it('spawns at the approval session limit with Orca entries withdrawn', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + grantInternals.setGrantSessionRunner( + (request: CodexHookTrustGrantRequest) => + new Promise((_resolve, reject) => + setTimeout( + () => reject(new CodexAppServerTimeoutError('codex app-server session timed out')), + request.invocation.timeoutMs + ) + ) + ) + let spawned = false + let trustAtSpawn: string[] | null = null + const resumed = resume().then(() => { + spawned = true + trustAtSpawn = realHomeOrcaEntryTrust() + }) + + await vi.advanceTimersByTimeAsync(CODEX_BACKGROUND_TRUST_GRANT_TIMEOUT_MS - 1) + expect(spawned).toBe(false) + await vi.advanceTimersByTimeAsync(1) + await resumed + // Why empty: no unapproved Orca entry is left for Codex to put up for review. + expect(trustAtSpawn).toEqual([]) + }) + + it('makes panes restored together wait on one approval session', async () => { + let approve: () => void = () => {} + const approval = new Promise((resolve) => { + approve = resolve + }) + let sessions = 0 + grantInternals.setGrantSessionRunner(async (request: CodexHookTrustGrantRequest) => { + sessions += 1 + await approval + return approveAll(request) + }) + + const restored = Promise.all([resume(), resume(), resume()]) + expect(await settlesWithin(restored, 200)).toBe(false) + expect(sessions).toBe(1) + approve() + await restored + expect(sessions).toBe(1) + const trust = realHomeOrcaEntryTrust() + expect(trust.length).toBeGreaterThan(0) + expect(trust.every((state) => state === 'trusted')).toBe(true) + }) + it.each(['pending', 'installed', 'unavailable', 'removed'] as const)( + 'makes resumes restored together from %s share one session, each spawning at its limit', + async (verdict) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + realHomeInternals.resetForTesting(verdict) + const record = timeOutRealHomeSessions() + const restored = resumeTogether(3) + let launchSettled = false + const launched = launch(workspaceDirs()[0]).finally(() => { + launchSettled = true + }) + + await vi.advanceTimersByTimeAsync(0) + // Why: a launch never waits on the approval the resumes wait for. + expect(launchSettled).toBe(true) + expect(await launched).toBe(getOrcaManagedCodexHomePath()) + await vi.advanceTimersByTimeAsync(CODEX_BACKGROUND_TRUST_GRANT_TIMEOUT_MS) + await restored.done + expect(record.outcomes).toEqual(['timeout']) + expect(restored.spawnedAt).toEqual(Array(3).fill(CODEX_BACKGROUND_TRUST_GRANT_TIMEOUT_MS)) + expect(realHomeOrcaEntryTrust()).toEqual([]) + } + ) + + it('shares the approval the startup check started with panes restored after it', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const record = timeOutRealHomeSessions() + expect( + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: homes.userDataDir, + writePolicy: 'convert-older-forms' + }) + ).toBe('approving') + const restored = resumeTogether(3) + + await vi.advanceTimersByTimeAsync(CODEX_BACKGROUND_TRUST_GRANT_TIMEOUT_MS) + await restored.done + expect(record.outcomes).toEqual(['timeout']) + expect(restored.spawnedAt).toEqual(Array(3).fill(CODEX_BACKGROUND_TRUST_GRANT_TIMEOUT_MS)) + }) + + it('runs no session on a plan a failed approval already withdrew', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const record = timeOutRealHomeSessions() + const restored = resumeTogether(2) + + await vi.advanceTimersByTimeAsync(CODEX_BACKGROUND_TRUST_GRANT_TIMEOUT_MS) + await restored.done + expect(record.outcomes).toEqual(['timeout']) + + // Why: the next check writes and approves from what hooks.json holds now. + await resume() + expect(record.outcomes).toEqual(['timeout', 'granted']) + expect(realHomeOrcaEntryTrust().every((state) => state === 'trusted')).toBe(true) + }) + + it('keeps a resume waiting when hooks read off mid-approval while Orca entries are unapproved', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + timeOutRealHomeSessions() + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: homes.userDataDir, + writePolicy: 'add-missing-only' + }) + // Why: the settings flipped, and the opt-out sweep has not run yet. + settings.agentStatusHooksEnabled = false + const restored = resumeTogether(1) + + await vi.advanceTimersByTimeAsync(0) + expect(restored.spawnedAt).toEqual([]) + expect(isRealHomeCodexHookLaneUsable()).toBe(false) + await vi.advanceTimersByTimeAsync(CODEX_BACKGROUND_TRUST_GRANT_TIMEOUT_MS) + await restored.done + expect(restored.spawnedAt).toEqual([CODEX_BACKGROUND_TRUST_GRANT_TIMEOUT_MS]) + expect(realHomeOrcaEntryTrust()).toEqual([]) + }) + + it('lets a resume start once the opt-out removed the unapproved entries', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + timeOutRealHomeSessions() + await ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: homes.userDataDir, + writePolicy: 'add-missing-only' + }) + settings.agentStatusHooksEnabled = false + await removeRealHomeCodexHookForOptOut() + const restored = resumeTogether(1) + + await vi.advanceTimersByTimeAsync(0) + await restored.done + expect(restored.spawnedAt).toEqual([0]) + await vi.advanceTimersByTimeAsync(CODEX_BACKGROUND_TRUST_GRANT_TIMEOUT_MS) + await realHomeInternals.settledVerdictForTesting() + }) +}) diff --git a/src/main/startup/codex-launch-per-agent-hook-opt-out.test.ts b/src/main/startup/codex-launch-per-agent-hook-opt-out.test.ts index 9191fb1666b7..f854fbc47f8b 100644 --- a/src/main/startup/codex-launch-per-agent-hook-opt-out.test.ts +++ b/src/main/startup/codex-launch-per-agent-hook-opt-out.test.ts @@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => { installForLaunchPrep: vi.fn(async () => {}), refreshRuntimeUserHooksForLaunchPrep: vi.fn(async () => {}), ensureRealHomeCodexHookState: vi.fn(async () => 'installed' as const), + awaitRealHomeCodexHookTrust: vi.fn(async () => 'installed' as const), prepareCodexSessionResume: vi.fn() } }) @@ -34,6 +35,7 @@ vi.mock('../codex/hook-service', () => ({ } })) vi.mock('../codex/codex-real-home-hook-install', () => ({ + awaitRealHomeCodexHookTrust: mocks.awaitRealHomeCodexHookTrust, ensureRealHomeCodexHookState: mocks.ensureRealHomeCodexHookState })) // Why: the real predicate, without loading every agent's hook service. @@ -130,7 +132,7 @@ describe('Codex launch prep honours the per-agent hook opt-out', () => { expect(mocks.ensureRealHomeCodexHookState).toHaveBeenCalledTimes(1) expect(mocks.ensureRealHomeCodexHookState).toHaveBeenCalledWith( - expect.objectContaining({ hooksEnabled: codexHooksOn }) + expect.objectContaining({ hooksEnabled: codexHooksOn, writePolicy: 'add-missing-only' }) ) expect(mocks.prepareRuntimeHomeForLaunch).not.toHaveBeenCalled() } @@ -162,8 +164,10 @@ describe('Codex launch prep honours the per-agent hook opt-out', () => { expect(mocks.ensureRealHomeCodexHookState).toHaveBeenCalledTimes(1) expect(mocks.ensureRealHomeCodexHookState).toHaveBeenCalledWith( - expect.objectContaining({ hooksEnabled: codexHooksOn }) + expect.objectContaining({ hooksEnabled: codexHooksOn, writePolicy: 'add-missing-only' }) ) + // Why: a resume has no managed home to fall back to, so it waits on a running approval. + expect(mocks.awaitRealHomeCodexHookTrust).toHaveBeenCalledOnce() expect(mocks.installForLaunchPrep).not.toHaveBeenCalled() expect(mocks.refreshRuntimeUserHooksForLaunchPrep).not.toHaveBeenCalled() } @@ -177,6 +181,7 @@ describe('Codex launch prep honours the per-agent hook opt-out', () => { await resumeFrom(ACCOUNT_HOME) expect(mocks.ensureRealHomeCodexHookState).not.toHaveBeenCalled() + expect(mocks.awaitRealHomeCodexHookTrust).not.toHaveBeenCalled() if (codexHooksOn) { expect(mocks.installForLaunchPrep).toHaveBeenCalledWith(ACCOUNT_HOME) expect(mocks.refreshRuntimeUserHooksForLaunchPrep).not.toHaveBeenCalled() diff --git a/src/main/startup/codex-launch-preparation.ts b/src/main/startup/codex-launch-preparation.ts index 06e61c18836d..b70d61396840 100644 --- a/src/main/startup/codex-launch-preparation.ts +++ b/src/main/startup/codex-launch-preparation.ts @@ -20,13 +20,15 @@ export async function prepareCodexRuntimeHomeForLaunch( if (target?.runtime === 'wsl' || !runtimeHome.isHostSystemDefaultRealHomeSelected(launchEnv)) { return false } - // Why (flag ON, system default): the hook entry must exist — appended last - // and trusted by codex's own app-server grant — in the real ~/.codex before - // the pane spawns. An incapable grant flips the lane gate so the launch - // below falls back to the managed home instead of a status-blind pane. + // Why (flag ON, system default): the hook entry must exist, appended last, in + // the real ~/.codex before the pane spawns. This never waits on Codex's + // approval: until it lands, the lane gate sends the launch below to the + // managed home instead of a pane that would ask the user to review it. + // Add-only: another build's entry is left for app start to convert. await ensureRealHomeCodexHookState({ hooksEnabled: isAgentStatusHooksEnabledForAgent(state.store?.getSettings(), 'codex'), - userDataPath: app.getPath('userData') + userDataPath: app.getPath('userData'), + writePolicy: 'add-missing-only' }) return true } diff --git a/src/main/startup/codex-session-resume-launch.ts b/src/main/startup/codex-session-resume-launch.ts index a827ce2849f3..f01d17610d71 100644 --- a/src/main/startup/codex-session-resume-launch.ts +++ b/src/main/startup/codex-session-resume-launch.ts @@ -6,7 +6,10 @@ import { prepareCodexSessionResume } from '../codex/codex-session-resume-prepara import { prepareLegacySharedCodexSessionResume } from '../codex/codex-legacy-session-resume' import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership' import { codexHookService } from '../codex/hook-service' -import { ensureRealHomeCodexHookState } from '../codex/codex-real-home-hook-install' +import { + awaitRealHomeCodexHookTrust, + ensureRealHomeCodexHookState +} from '../codex/codex-real-home-hook-install' import { ensureCodexDaemonSocketGuard } from '../codex/codex-config-mirror' import { isAgentStatusHooksEnabledForAgent } from '../agent-hooks/managed-agent-hook-controls' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths' @@ -87,8 +90,12 @@ export async function prepareCodexSessionResumeForLaunch(args: { if (isSystemHome) { await ensureRealHomeCodexHookState({ hooksEnabled, - userDataPath: app.getPath('userData') + userDataPath: app.getPath('userData'), + writePolicy: 'add-missing-only' }) + // Why: beside an unapproved entry Codex opens a blocking hook-review screen, + // and only the grant's own settle cannot race Codex's approval write. + await awaitRealHomeCodexHookTrust() } else if (hooksEnabled) { await codexHookService.installForLaunchPrep(resumeHome) } else { diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index 42338910e2a2..6abf73d66106 100644 --- a/src/main/startup/main-process-account-services.ts +++ b/src/main/startup/main-process-account-services.ts @@ -19,9 +19,11 @@ import { createAccountRuntimeTargetSettingsSync } from '../rate-limits/account-r import { normalizeCodexRuntimeSelection } from '../codex-accounts/runtime-selection' import { normalizeClaudeRuntimeSelection } from '../claude-accounts/runtime-selection' import { agentHookServer } from '../agent-hooks/server' -import { setSystemCodexHomeHookSweepSuppressed } from '../codex/hook-service' -import { shouldSuppressSystemCodexHomeHookSweep } from '../codex/codex-hook-legacy-cleanup' -import { isRealHomeCodexHookLaneUsable } from '../codex/codex-real-home-hook-install' +import { + isRealHomeCodexHookLaneUsable, + setRealHomeCodexHooksEnabledReader +} from '../codex/codex-real-home-hook-install' +import { isAgentStatusHooksEnabledForAgent } from '../agent-hooks/managed-agent-hook-controls' import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' import { browserManager } from '../browser/browser-manager' import { mainProcessState as state } from './main-process-state' @@ -43,15 +45,8 @@ export function initializeMainProcessAccountServices(): void { // Why: an incapable trust-grant host must fall back to the managed home for // every consumer (PTY env, rate limits, commit messages) in one place. state.codexRuntimeHome.setRealHomeLaneGate(() => isRealHomeCodexHookLaneUsable()) - // Why: while the real-home lane owns ~/.codex/hooks.json, the legacy - // system-home sweep inside managed installs would delete the entry the - // real-home installer just appended. Flag OFF, hooks off (all or Codex), or an - // incapable trust lane re-arms the sweep so downgrade, opt-out, and rollback converge. - setSystemCodexHomeHookSweepSuppressed(() => - shouldSuppressSystemCodexHomeHookSweep({ - isHostSystemDefaultRealHome: state.codexRuntimeHome?.isHostSystemDefaultRealHome() === true, - settings: state.store?.getSettings() - }) + setRealHomeCodexHooksEnabledReader(() => + isAgentStatusHooksEnabledForAgent(store.getSettings(), 'codex') ) state.codexSessionMigration = createCodexSessionMigrationScheduler({ isEligible: () => diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts index f104a3ec3439..5a89c9e283f6 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -17,6 +17,7 @@ import { browserManager } from '../browser/browser-manager' import { stopCodexStateDbBackfillRecoveries } from '../codex/codex-state-db-backfill-recovery' import { stopCodexAccountSessionBridges } from '../codex/codex-account-session-bridge' import { awaitPackedRefsLockRelease } from '../git/local-repo-ref-maintenance' +import { stopBackgroundWorktreeRemovals } from '../worktree-background-removal' import { settleTeardownWithinDeadline, settleWithinMs } from '../quit-teardown-deadline' import { quitTeardownStartGate } from '../quit-teardown-start-gate' import { setUnreadDockBadgeCount } from '../dock/unread-badge' @@ -152,6 +153,8 @@ function installWillQuitHandler(): void { REF_MAINTENANCE_QUIT_DEADLINE_MS ).then(() => {}) state.uninstallRepoMaintenanceIdleGate = null + // Why stop, not wait: a delete can run for minutes, and its record makes the next start finish it. + stopBackgroundWorktreeRemovals() agentHookServer.stop() // Why Windows only: POSIX hooks short-circuit on ORCA_PANE_KEY, while Windows must register a // bare script path that cannot express the guard and would otherwise keep spawning after quit. diff --git a/src/main/startup/main-process-ready-runtime.ts b/src/main/startup/main-process-ready-runtime.ts index 26b920652df1..f3ea135b6a73 100644 --- a/src/main/startup/main-process-ready-runtime.ts +++ b/src/main/startup/main-process-ready-runtime.ts @@ -32,8 +32,10 @@ import { import { initializeMainProcessAutomations } from './main-process-automations' import { initializeMainProcessPlugins } from './main-process-plugins' import { collectWorktreeTrashSweepRoots, sweepStaleWorktreeTrash } from '../worktree-trash' +import { loadWorktreeRemovalRecords } from '../worktree-background-removal' import { runAfterFirstWindowShown } from './first-window-deferral' import { logStartupMilestone } from './startup-diagnostics' +import { refreshInstalledOpenCodeStatusPlugins } from '../opencode/opencode-status-plugin-startup-refresh' // Headless serve never opens a window, so the sweep still has to run off a timer there. const WORKTREE_TRASH_SWEEP_FALLBACK_MS = 15_000 @@ -43,6 +45,8 @@ export async function initializeReadyRuntimeServices(): Promise { if (!store) { throw new Error('Store must be initialized before ready services') } + // Why before any listing: a delete a quit or crash interrupted must show as Deleting from first paint. + await loadWorktreeRemovalRecords(store.getProfileStorageDirectory()) initializeMainProcessObservers() initializeMainProcessAccountServices() const runtime = initializeMainProcessRuntime() @@ -77,22 +81,26 @@ export async function initializeReadyRuntimeServices(): Promise { // Why: externally started serve-sim processes must stay independent — only Orca-managed/attached helpers belong to a workspace. state.emulatorBridge = new EmulatorBridge() runtime.setEmulatorBridge(state.emulatorBridge) - // Why: worktree deletion renames the checkout aside and deletes it in the background, so a quit or - // crash mid-delete can leave the moved directory on disk. Why deferred: the sweep's recursive - // readdir/rm runs on the same libuv threadpool the window's first paint and worktree-catalog - // hydration are reading disk on, and nothing on the startup path consumes its result. + // Why: older releases renamed removed checkouts into a trash root and deleted them in the background, + // so a quit mid-delete left directories on disk; drain them. Removals this version recorded are + // finished by the same delete. Why deferred: both touch disk on the same libuv threadpool the + // window's first paint and worktree-catalog hydration read on, and startup consumes neither. runAfterFirstWindowShown(() => { void sweepStaleWorktreeTrash( collectWorktreeTrashSweepRoots(store.getRepos(), store.getSettings()) ).catch((error) => { console.warn('[worktrees] Failed to sweep leftover worktree directories:', error) }) + runtime.finishInterruptedWorktreeRemovals() + }, WORKTREE_TRASH_SWEEP_FALLBACK_MS) + // Why deferred: nothing on the startup path needs it, and it only rewrites plugin files that changed. + runAfterFirstWindowShown(() => { + refreshInstalledOpenCodeStatusPlugins(store.getSettings()) }, WORKTREE_TRASH_SWEEP_FALLBACK_MS) nativeTheme.themeSource = store.getSettings().theme ?? 'system' - // Why (#16441): the real-home grant runs a codex app-server session. It stays - // ordered before managed-hook reconciliation — an incapable host must re-arm - // and complete the legacy real-home sweep first — but awaiting it inline - // stalled app init behind that session, so chain instead of blocking. + // Why: the real-home ensure stays ordered before managed-hook reconciliation, so its + // in-slot conversion lands before the managed install's retired-form sweep removes + // the prior command. Codex's approval then runs in the background (#16441). const startupManagedHookSettings = store.getSettings() const shouldReconcileStartupManagedHooks = shouldInstallManagedHooks(is.dev) && @@ -103,7 +111,9 @@ export async function initializeReadyRuntimeServices(): Promise { state.codexRuntimeHome?.isHostSystemDefaultRealHomeSelected() ? ensureRealHomeCodexHookState({ hooksEnabled: true, - userDataPath: app.getPath('userData') + userDataPath: app.getPath('userData'), + // Why app start: the one place an older build's entry becomes the frozen command. + writePolicy: 'convert-older-forms' }).catch((error: unknown) => { console.warn('[codex-real-home-hooks] startup ensure failed:', error) }) diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index a000c57760a9..cfd0fa294020 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -101,7 +101,8 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { publish: (summary, subject) => agentHookServer.ingestStructuredStatus(summary, subject), forget: (subject) => agentHookServer.dropStructuredStatus(subject), publishChildWork: (subject, evidence, provider) => - agentHookServer.ingestStructuredChildWork(subject, evidence, provider) + agentHookServer.ingestStructuredChildWork(subject, evidence, provider), + readChildWork: (subject) => agentHookServer.getStructuredChildWorkViews(subject) }, // Why captured rather than resolved at read: the fleet snapshot remints cached rows on every // read, so a row observed under one process otherwise acquires whatever the pane owns now. @@ -116,6 +117,7 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { agentHookServer.attestCompatibilityAuthority(candidate), retireAgentHookCompatibilityAuthority: (paneKey) => agentHookServer.retirePaneAuthority(paneKey), + checkHookAgentPresence: (paneKey) => agentHookServer.checkAgentPresence(paneKey), reconcileAgentStatusForEndedProcess: (paneKeys) => agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), canRecoverPersistentLocalPtys: () => getDaemonProvider() !== null, diff --git a/src/main/text-generation/commit-message-text-generation-generated-output.test.ts b/src/main/text-generation/commit-message-text-generation-generated-output.test.ts index 2d9167338f58..21928f675ef4 100644 --- a/src/main/text-generation/commit-message-text-generation-generated-output.test.ts +++ b/src/main/text-generation/commit-message-text-generation-generated-output.test.ts @@ -54,6 +54,52 @@ describe('generateCommitMessageFromContext', () => { }) }) + it('drops prefilled reasoning from a custom command but not from a built-in agent', async () => { + const stdout = + "We need the message only.\r\nLet's final.Fix typo in README.md\r\n\r\n- Correct spelling\r\n" + const generate = (params: { agentId: 'custom' | 'claude'; model: string }) => + generateCommitMessageFromContext( + { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' }, + { ...params, customAgentCommand: 'llama-completion' }, + { + kind: 'remote', + cwd: '/repo', + missingBinaryLocation: 'remote PATH', + execute: async () => ({ stdout, stderr: '', exitCode: 0, timedOut: false }) + } + ) + + await expect(generate({ agentId: 'custom', model: '' })).resolves.toEqual({ + success: true, + message: 'Fix typo in README.md\n\n- Correct spelling', + agentLabel: 'llama-completion' + }) + await expect(generate({ agentId: 'claude', model: 'sonnet' })).resolves.toMatchObject({ + success: true, + message: expect.stringContaining("Let's final.Fix typo in README.md") + }) + }) + + it('reports a custom command that printed only reasoning as an empty message', async () => { + const result = await generateCommitMessageFromContext( + { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' }, + { agentId: 'custom', model: '', customAgentCommand: 'agent' }, + { + kind: 'remote', + cwd: '/repo', + missingBinaryLocation: 'remote PATH', + execute: async () => ({ + stdout: 'Still reasoning.\n', + stderr: '', + exitCode: 0, + timedOut: false + }) + } + ) + + expect(result).toEqual({ success: false, error: 'agent returned an empty message.' }) + }) + it('reports empty remote commit-message output as an empty message', async () => { let operation = '' const result = await generateCommitMessageFromContext( @@ -90,6 +136,40 @@ describe('generateCommitMessageFromContext', () => { }) }) + it('keeps custom-command PR JSON that quotes a lone closing tag, and strips reasoning otherwise', async () => { + const generate = (stdout: string) => + generatePullRequestFieldsFromContext( + { + branch: 'feature/pr-fields', + base: 'main', + branchChangedByPreparation: false, + currentTitle: '', + currentBody: '', + currentDraft: false, + commitSummary: '- feat: update README', + changeSummary: 'M\tREADME.md', + patch: '+hello' + }, + { agentId: 'custom', model: '', customAgentCommand: 'agent' }, + { + kind: 'remote', + cwd: '/repo', + missingBinaryLocation: 'remote PATH', + execute: async () => ({ stdout, stderr: '', exitCode: 0, timedOut: false }) + } + ) + const json = '{"title":"Strip from output","body":"Drops reasoning.","draft":false}' + + await expect(generate(json)).resolves.toMatchObject({ + success: true, + fields: { title: 'Strip from output', body: 'Drops reasoning.' } + }) + await expect(generate(`Need JSON only.\n${json}`)).resolves.toMatchObject({ + success: true, + fields: { title: 'Strip from output' } + }) + }) + it('reports empty remote pull-request field output as empty details', async () => { let operation = '' const result = await generatePullRequestFieldsFromContext( diff --git a/src/main/text-generation/source-control-text-generation-requests.ts b/src/main/text-generation/source-control-text-generation-requests.ts index 596ebc891cf7..a061201ac8c6 100644 --- a/src/main/text-generation/source-control-text-generation-requests.ts +++ b/src/main/text-generation/source-control-text-generation-requests.ts @@ -14,7 +14,12 @@ import { sanitizeBranchSlug, type BranchNameWorkContext } from '../../shared/branch-name-from-work' -import type { CommandTemplateBackslash } from '../../shared/commit-message-prompt' +import { + cleanGeneratedCommitMessage, + stripPrefilledReasoningPreamble, + type CommandTemplateBackslash +} from '../../shared/commit-message-prompt' +import { isCustomAgentId } from '../../shared/commit-message-agent-spec' import { planCommitMessageGeneration, type CommitMessagePlan @@ -56,7 +61,7 @@ async function executeGenerationPlan(input: { operation: TextGenerationOperation spawnAgent: SpawnSourceControlAgent }): Promise { - return input.target.kind === 'remote' + const result = await (input.target.kind === 'remote' ? runRemoteSourceControlPlan({ plan: input.plan, target: input.target, @@ -70,7 +75,25 @@ async function executeGenerationPlan(input: { emptyResultName: input.emptyResultName, operation: input.operation, spawnAgent: input.spawnAgent - }) + })) + // Why: only a custom command runs a raw model whose chat template can swallow + // the opening think tag; a built-in agent's message may just mention the tag. + // PR fields are JSON, so they strip only when parsing fails instead. + if ( + !result.success || + !isCustomAgentId(input.params.agentId) || + input.operation === 'pull-request-fields' + ) { + return result + } + const answer = stripPrefilledReasoningPreamble(result.rawOutput) + if (answer === result.rawOutput) { + return result + } + const rawOutput = cleanGeneratedCommitMessage(answer) + return rawOutput + ? { ...result, rawOutput } + : { success: false, error: `${input.plan.label} returned an empty ${input.emptyResultName}.` } } export async function generateCommitMessage(input: { @@ -168,7 +191,7 @@ export async function generatePullRequestFields(input: { try { return { success: true, - fields: parseGeneratedPullRequestFields(result.rawOutput, context), + fields: parsePullRequestFieldsOutput(result.rawOutput, context, params.agentId), agentLabel: result.agentLabel, branchChangedByPreparation: context.branchChangedByPreparation } @@ -181,6 +204,24 @@ export async function generatePullRequestFields(input: { } } +// Why: a body may quote a lone closing tag inside valid JSON, so reasoning is +// stripped only when the untouched output does not parse. +function parsePullRequestFieldsOutput( + raw: string, + context: PullRequestDraftContext, + agentId: GenerateParams['agentId'] +): GeneratedPullRequestFields { + try { + return parseGeneratedPullRequestFields(raw, context) + } catch (error) { + const answer = isCustomAgentId(agentId) ? stripPrefilledReasoningPreamble(raw) : raw + if (answer === raw) { + throw error + } + return parseGeneratedPullRequestFields(cleanGeneratedCommitMessage(answer), context) + } +} + export async function generateBranchName(input: { context: BranchNameWorkContext params: GenerateParams diff --git a/src/main/updater.linux-externally-managed.test.ts b/src/main/updater.linux-externally-managed.test.ts index 0b63a8ec9ed3..ba7ab0452133 100644 --- a/src/main/updater.linux-externally-managed.test.ts +++ b/src/main/updater.linux-externally-managed.test.ts @@ -105,17 +105,6 @@ describe('updater externally managed Linux installs', () => { }) }) - it('marks the refusal non-retryable so the card offers no Retry Download', async () => { - const { send, updater } = await startUpdater({ packageType: 'deb', externallyManaged: true }) - updater.checkForUpdatesFromMenu() - await vi.advanceTimersByTimeAsync(0) - updater.downloadUpdate() - await vi.advanceTimersByTimeAsync(0) - - const status = lastStatus(send) - expect(status?.state === 'error' && status.retryable).toBe(false) - }) - it('records the blocked download for field diagnosis', async () => { const { updater } = await startUpdater({ packageType: 'deb', externallyManaged: true }) updater.checkForUpdatesFromMenu() diff --git a/src/main/updater.prerelease-fallback.test.ts b/src/main/updater.prerelease-fallback.test.ts index 49ed4ca1bb62..ae0349800744 100644 --- a/src/main/updater.prerelease-fallback.test.ts +++ b/src/main/updater.prerelease-fallback.test.ts @@ -690,30 +690,4 @@ describe('updater', () => { url: 'https://github.com/stablyai/orca/releases/download/v1.3.18' }) }) - - // Why: native GitHub provider can pick cancelled prerelease tags with missing manifests, so keep the manifest-probed generic feed. - it('uses the manifest-probed generic feed after a Shift-click RC opt-in', async () => { - appMock.getVersion.mockReturnValue('1.3.17') - fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.18-rc.1']) - autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) - - const { setupAutoUpdater, checkForUpdatesFromMenu } = await loadUpdaterModule() - - const mainWindow = { webContents: { send: vi.fn() } } - setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) - - checkForUpdatesFromMenu({ includePrerelease: true }) - - await vi.waitFor(() => { - expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.3.17', 2, { - includePrerelease: true - }) - expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) - }) - expect(autoUpdaterMock.allowPrerelease).toBe(true) - expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ - provider: 'generic', - url: 'https://github.com/stablyai/orca/releases/download/v1.3.18-rc.1' - }) - }) }) diff --git a/src/main/updater.startup-scheduling.test.ts b/src/main/updater.startup-scheduling.test.ts index ef72af27defa..285743295608 100644 --- a/src/main/updater.startup-scheduling.test.ts +++ b/src/main/updater.startup-scheduling.test.ts @@ -268,17 +268,4 @@ describe('updater', () => { expect((autoUpdaterMock as Record).verifyUpdateCodeSignature).toBeUndefined() }) - - it('does not override verifyUpdateCodeSignature on non-Windows platforms', async () => { - vi.stubGlobal('process', { ...process, platform: 'darwin' }) - - const { setupAutoUpdater } = await loadUpdaterModule() - - const sendMock = vi.fn() - const mainWindow = { webContents: { send: sendMock } } - - setupAutoUpdater(mainWindow as never) - - expect((autoUpdaterMock as Record).verifyUpdateCodeSignature).toBeUndefined() - }) }) diff --git a/src/main/usage/usage-scan-worker-client.test.ts b/src/main/usage/usage-scan-worker-client.test.ts index 09c6f378f007..c88389203350 100644 --- a/src/main/usage/usage-scan-worker-client.test.ts +++ b/src/main/usage/usage-scan-worker-client.test.ts @@ -1,13 +1,10 @@ -import { join, sep } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { resolveWorkerThreadEntryPath } from '../worker-thread-entry-path' import { MAX_CONSECUTIVE_DEATHS, USAGE_SCAN_NO_PROGRESS_TIMEOUT_MS, UsageScanWorkerClient, scanCodexUsageOnWorker } from './usage-scan-worker-client' -import { USAGE_SCAN_WORKER_ENTRY_FILENAME } from './usage-scan-worker-spawn' import type { UsageScanWorkerRequest, UsageScanWorkerRequestBody @@ -215,23 +212,3 @@ describe('UsageScanWorkerClient', () => { await expect(pending).rejects.toThrow(/answered for claude/) }) }) - -// Why: the packaged branch never runs in dev or e2e (both take the __dirname -// path), so it is pinned here at the path-construction level. -describe('usage scan worker entry path', () => { - it('resolves a packaged build under resourcesPath/app.asar/out/main', () => { - const resourcesPath = join(sep, 'Applications', 'Orca.app', 'Contents', 'Resources') - - const resolved = resolveWorkerThreadEntryPath( - { isPackaged: true, resourcesPath, moduleDir: join(sep, 'unpackaged', 'out', 'main') }, - USAGE_SCAN_WORKER_ENTRY_FILENAME - ) - - expect(resolved.slice(resourcesPath.length + 1).split(sep)).toEqual([ - 'app.asar', - 'out', - 'main', - USAGE_SCAN_WORKER_ENTRY_FILENAME - ]) - }) -}) diff --git a/src/main/window/runtime-window-lifecycle-file-open.test.ts b/src/main/window/runtime-window-lifecycle-file-open.test.ts new file mode 100644 index 000000000000..f901c6be83c7 --- /dev/null +++ b/src/main/window/runtime-window-lifecycle-file-open.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest' +import type { BrowserWindow } from 'electron' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { RuntimeNotifier } from '../runtime/runtime-notifier-contract' + +vi.mock('electron', () => ({ ipcMain: { on: vi.fn(), removeListener: vi.fn() } })) +vi.mock('../ipc/worktree-change-invalidators', () => ({ runWorktreeChangeInvalidators: vi.fn() })) +vi.mock('./mobile-markdown-request-relay', () => ({ requestMobileMarkdownFromRenderer: vi.fn() })) +vi.mock('./renderer-document-navigation', () => ({ registerRendererDocumentNavigation: vi.fn() })) +vi.mock('./session-tab-close-request-relay', () => ({ + requestSessionTabCloseFromRenderer: vi.fn() +})) +vi.mock('./terminal-tab-close-request-relay', () => ({ + requestTerminalTabCloseFromRenderer: vi.fn() +})) + +import { registerRuntimeWindowLifecycle } from './runtime-window-lifecycle' + +function attachNotifier(): { notifier: RuntimeNotifier; send: ReturnType } { + const send = vi.fn() + const attached: { notifier: RuntimeNotifier | null } = { notifier: null } + const mainWindow = { + id: 1, + isDestroyed: () => false, + on: vi.fn(), + webContents: { isDestroyed: () => false, send, on: vi.fn() } + } + const runtime = { + attachWindow: vi.fn(), + markGraphReloadFailed: vi.fn(), + setNotifier: (next: RuntimeNotifier | null) => { + attached.notifier = next + } + } + registerRuntimeWindowLifecycle( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registration only reads id, isDestroyed, on and webContents. + mainWindow as unknown as BrowserWindow, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registration only calls the stubbed runtime members. + runtime as unknown as OrcaRuntimeService + ) + if (!attached.notifier) { + throw new Error('runtime notifier was not attached') + } + return { notifier: attached.notifier, send } +} + +describe('runtime window file-open notifications', () => { + it('omits navigation when the caller sent none (legacy switch)', () => { + const { notifier, send } = attachNotifier() + + notifier.openFile?.('wt-1', '/repo/a.ts', 'a.ts', undefined) + notifier.openDiff?.('wt-1', '/repo/a.ts', 'a.ts', true, undefined) + + expect(send).toHaveBeenCalledWith('ui:openFileFromMobile', { + worktreeId: 'wt-1', + filePath: '/repo/a.ts', + relativePath: 'a.ts', + runtimeEnvironmentId: undefined + }) + expect(send).toHaveBeenCalledWith('ui:openDiffFromMobile', { + worktreeId: 'wt-1', + filePath: '/repo/a.ts', + relativePath: 'a.ts', + staged: true, + runtimeEnvironmentId: undefined + }) + for (const [, payload] of send.mock.calls) { + expect(payload).not.toHaveProperty('navigation') + } + }) + + it('forwards an explicit navigation target to the renderer', () => { + const { notifier, send } = attachNotifier() + + notifier.openFile?.('wt-1', '/repo/a.ts', 'a.ts', undefined, 'all') + notifier.openDiff?.('wt-1', '/repo/a.ts', 'a.ts', false, undefined, 'caller') + + expect(send).toHaveBeenCalledWith( + 'ui:openFileFromMobile', + expect.objectContaining({ navigation: 'all' }) + ) + expect(send).toHaveBeenCalledWith( + 'ui:openDiffFromMobile', + expect.objectContaining({ navigation: 'caller' }) + ) + }) +}) diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index 142389099c0f..0322a816d709 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -169,20 +169,22 @@ export function registerRuntimeWindowLifecycle( requestSessionTabCloseFromRenderer(mainWindow, tabId, worktreeId), moveSessionTab: (worktreeId: string, move: RuntimeMobileSessionTabMove) => send('ui:moveSessionTab', { worktreeId, ...move }), - openFile: (worktreeId, filePath, relativePath, runtimeEnvironmentId?) => + openFile: (worktreeId, filePath, relativePath, runtimeEnvironmentId?, navigation?) => send('ui:openFileFromMobile', { worktreeId, filePath, relativePath, - runtimeEnvironmentId + runtimeEnvironmentId, + ...(navigation ? { navigation } : {}) }), - openDiff: (worktreeId, filePath, relativePath, staged, runtimeEnvironmentId?) => + openDiff: (worktreeId, filePath, relativePath, staged, runtimeEnvironmentId?, navigation?) => send('ui:openDiffFromMobile', { worktreeId, filePath, relativePath, staged, - runtimeEnvironmentId + runtimeEnvironmentId, + ...(navigation ? { navigation } : {}) }), readMobileMarkdownTab: (worktreeId, tabId) => requestMobileMarkdownFromRenderer(mainWindow, { diff --git a/src/main/windows/windows-pty-job.ts b/src/main/windows/windows-pty-job.ts index 814f2dbaa8ab..5b949c939a0c 100644 --- a/src/main/windows/windows-pty-job.ts +++ b/src/main/windows/windows-pty-job.ts @@ -1,6 +1,4 @@ import type { IPty } from 'node-pty' -import { canUseBunPty } from '../daemon/pty-subprocess/bun-pty-process-capabilities' -import { loadWindowsBunPtyJobNative } from '../daemon/pty-subprocess/windows-bun-pty-native' import { createRequire } from 'node:module' import { recordSelfInitiatedTreeKill } from '../crash-reporting/self-initiated-tree-kill-log' @@ -233,7 +231,7 @@ function assignHostProcessOnce(): boolean { /** Whether this build can own PTY trees with job objects at all. */ export function isPtyJobOwnershipAvailable(): boolean { - return canUseBunPty() ? loadWindowsBunPtyJobNative() !== null : nativeLoader() !== null + return nativeLoader() !== null } /** Test-only: substitute the native module (it is resolved via createRequire). */ diff --git a/src/main/worktree-background-removal-records.test.ts b/src/main/worktree-background-removal-records.test.ts new file mode 100644 index 000000000000..6707acfe271d --- /dev/null +++ b/src/main/worktree-background-removal-records.test.ts @@ -0,0 +1,257 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + _resetPendingWorktreeRemovalsForTests, + _settlePendingWorktreeRemovalsForTests, + loadWorktreeRemovalRecords, + projectPendingWorktreeRemovals, + resumeInterruptedWorktreeRemovals, + snapshotPendingWorktreeRemovals, + startBackgroundWorktreeRemoval, + stopBackgroundWorktreeRemovals, + waitForPendingWorktreeRemoval +} from './worktree-background-removal' +import type * as WorktreeRemovalRecords from './worktree-removal-records' +import { + readWorktreeRemovalRecords, + worktreeRemovalRecordsFile, + writeWorktreeRemovalRecords +} from './worktree-removal-records' + +vi.mock('./worktree-removal-records', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeWorktreeRemovalRecords: vi.fn(actual.writeWorktreeRemovalRecords) + } +}) + +const removal = { + worktreeId: 'repo-1::/work/feature', + repoId: 'repo-1', + repoPath: '/work/repo', + worktree: { path: '/work/feature', branch: 'refs/heads/feature', head: 'abc' }, + deleteBranch: true, + force: false +} +const isPending = (): boolean => waitForPendingWorktreeRemoval(removal.worktreeId) !== undefined +let directory = '' + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-worktree-removal-records-')) + await loadWorktreeRemovalRecords(directory) +}) + +afterEach(async () => { + vi.useRealTimers() + _resetPendingWorktreeRemovalsForTests() + await rm(directory, { recursive: true, force: true }) +}) + +/** A delete Git never finishes on its own, like one a quit or crash cuts short. */ +function interruptedJob(): { + run: (stopSignal: AbortSignal) => Promise + publish: () => void + started: () => boolean + stopped: () => boolean +} { + let started = false + let stopped = false + return { + run: (stopSignal) => + new Promise((_resolve, reject) => { + started = true + stopSignal.addEventListener('abort', () => { + stopped = true + reject(new Error('The operation was aborted.')) + }) + }), + publish: vi.fn(), + started: () => started, + stopped: () => stopped + } +} + +describe('durable worktree removal records', () => { + it('writes the record before Git starts and clears it once the delete succeeds', async () => { + let recordedAtGitStart: unknown[] = [] + void startBackgroundWorktreeRemoval({ + removal, + run: async () => { + recordedAtGitStart = await readWorktreeRemovalRecords(directory) + return {} + }, + publish: () => {} + }) + await _settlePendingWorktreeRemovalsForTests() + + expect(recordedAtGitStart).toEqual([ + { + worktreeId: removal.worktreeId, + repoId: 'repo-1', + repoPath: '/work/repo', + worktreePath: '/work/feature', + branch: 'feature', + head: 'abc', + deleteBranch: true, + force: false, + requestedAt: expect.any(Number) + } + ]) + expect(await readWorktreeRemovalRecords(directory)).toEqual([]) + }) + + it('clears the record when the delete fails so the row returns live and retryable', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const publish = vi.fn() + const result = startBackgroundWorktreeRemoval({ + removal, + run: async () => { + throw new Error('Permission denied') + }, + publish + }) + await expect(result).rejects.toThrow('Permission denied') + await _settlePendingWorktreeRemovalsForTests() + + expect(await readWorktreeRemovalRecords(directory)).toEqual([]) + expect(isPending()).toBe(false) + // Once on acceptance, once after the row left the table. + expect(publish).toHaveBeenCalledTimes(2) + }) + + it('starts Git after a bounded wait when the record write stalls', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + vi.mocked(writeWorktreeRemovalRecords).mockReturnValueOnce(new Promise(() => {})) + const run = vi.fn(async () => ({})) + void startBackgroundWorktreeRemoval({ + removal, + run, + publish: () => {} + }) + + await vi.advanceTimersByTimeAsync(1_000) + expect(run).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1_000) + expect(run).toHaveBeenCalledTimes(1) + vi.useRealTimers() + await _settlePendingWorktreeRemovalsForTests() + }) + + it('replies without waiting for the record to be cleared on disk', async () => { + let finishClear = (): void => {} + const publish = vi.fn() + const result = startBackgroundWorktreeRemoval({ + removal, + run: async () => { + vi.mocked(writeWorktreeRemovalRecords).mockReturnValueOnce( + new Promise((resolve) => { + finishClear = resolve + }) + ) + return {} + }, + publish + }) + + await expect(result).resolves.toEqual({}) + expect(publish).toHaveBeenCalledTimes(2) + finishClear() + await _settlePendingWorktreeRemovalsForTests() + }) + + it('stops Git on quit without waiting and keeps the record for the next start', async () => { + const job = interruptedJob() + void startBackgroundWorktreeRemoval({ removal, ...job }) + await vi.waitFor(() => expect(job.started()).toBe(true)) + + expect(stopBackgroundWorktreeRemovals()).toBeUndefined() + expect(job.stopped()).toBe(true) + await _settlePendingWorktreeRemovalsForTests() + + expect(await readWorktreeRemovalRecords(directory)).toHaveLength(1) + // Only the start was published: a stopped delete is neither removed nor failed. + expect(job.publish).toHaveBeenCalledTimes(1) + }) + + it('marks the row as removing after a restart and finishes it with the same job', async () => { + const job = interruptedJob() + void startBackgroundWorktreeRemoval({ removal, ...job }) + await vi.waitFor(() => expect(job.started()).toBe(true)) + stopBackgroundWorktreeRemovals() + await _settlePendingWorktreeRemovalsForTests() + + // Restart: nothing in memory, only the file. + _resetPendingWorktreeRemovalsForTests() + expect(isPending()).toBe(false) + await loadWorktreeRemovalRecords(directory) + const joined = waitForPendingWorktreeRemoval(removal.worktreeId) + const rows: { id: string; hostId?: undefined }[] = [ + { id: removal.worktreeId }, + { id: 'repo-1::/work/other' } + ] + expect( + projectPendingWorktreeRemovals(rows, (row) => row.id, true, snapshotPendingWorktreeRemovals()) + ).toEqual([{ id: removal.worktreeId, removing: true }, { id: 'repo-1::/work/other' }]) + expect( + projectPendingWorktreeRemovals( + rows, + (row) => row.id, + false, + snapshotPendingWorktreeRemovals() + ) + ).toEqual([{ id: 'repo-1::/work/other' }]) + + const publish = vi.fn() + const resumed: string[] = [] + resumeInterruptedWorktreeRemovals((record) => ({ + run: async () => { + resumed.push(`${record.worktreePath} ${record.branch} ${record.deleteBranch}`) + return {} + }, + publish + })) + await _settlePendingWorktreeRemovalsForTests() + + expect(resumed).toEqual(['/work/feature feature true']) + expect(isPending()).toBe(false) + expect(await readWorktreeRemovalRecords(directory)).toEqual([]) + // A request that joined after the restart gets the resumed delete's result. + await expect(joined).resolves.toEqual({}) + expect(publish).toHaveBeenCalledTimes(1) + }) + + it('does not start a second job for a removal that is still running', async () => { + void startBackgroundWorktreeRemoval({ removal, ...interruptedJob() }) + const jobFor = vi.fn() + resumeInterruptedWorktreeRemovals(jobFor) + expect(jobFor).not.toHaveBeenCalled() + stopBackgroundWorktreeRemovals() + await _settlePendingWorktreeRemovalsForTests() + }) + + it('reads back only well-formed records from the file', async () => { + const valid = { + worktreeId: 'repo-1::/work/a', + repoId: 'repo-1', + repoPath: '/work/repo', + worktreePath: '/work/a', + branch: '', + head: '', + deleteBranch: false, + force: true, + requestedAt: 5 + } + await writeFile( + worktreeRemovalRecordsFile(directory), + JSON.stringify({ version: 1, removals: [valid, { worktreeId: 'x' }, null] }) + ) + expect(await readWorktreeRemovalRecords(directory)).toEqual([valid]) + + await writeFile(worktreeRemovalRecordsFile(directory), '{not json') + expect(await readWorktreeRemovalRecords(directory)).toEqual([]) + }) +}) diff --git a/src/main/worktree-background-removal.test.ts b/src/main/worktree-background-removal.test.ts new file mode 100644 index 000000000000..2ddc1952e8eb --- /dev/null +++ b/src/main/worktree-background-removal.test.ts @@ -0,0 +1,254 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ExecutionHostId } from '../shared/execution-host' +import { + _resetPendingWorktreeRemovalsForTests, + _settlePendingWorktreeRemovalsForTests, + assertNoPendingWorktreeRemovalConflict, + finishAcceptedWorktreeRemoval, + projectPendingWorktreeRemovals, + removesInBackground, + snapshotPendingWorktreeRemovals, + startBackgroundWorktreeRemoval, + waitForPendingWorktreeRemoval +} from './worktree-background-removal' + +const removal = { + worktreeId: 'repo-1::/work/feature', + repoId: 'repo-1', + repoPath: '/work/repo', + worktree: { path: '/work/feature', branch: 'refs/heads/feature', head: 'abc' }, + deleteBranch: true, + force: false +} +const isPending = (hostId?: ExecutionHostId): boolean => + waitForPendingWorktreeRemoval(removal.worktreeId, hostId) !== undefined + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +describe('background worktree removal', () => { + afterEach(() => { + _resetPendingWorktreeRemovalsForTests() + }) + + it('resolves with the delete result once Git finishes, after the row has left the table', async () => { + const git = deferred<{ preservedBranch: { branchName: string; head: string } }>() + const pendingAtPublish: boolean[] = [] + const result = startBackgroundWorktreeRemoval({ + removal, + run: () => git.promise, + publish: () => pendingAtPublish.push(isPending()) + }) + + expect(isPending()).toBe(true) + expect(pendingAtPublish).toEqual([true]) + + git.resolve({ preservedBranch: { branchName: 'feature', head: 'abc' } }) + await expect(result).resolves.toEqual({ + preservedBranch: { branchName: 'feature', head: 'abc' } + }) + await _settlePendingWorktreeRemovalsForTests() + expect(isPending()).toBe(false) + // A refetch the end notice triggers must not see the row as still removing. + expect(pendingAtPublish).toEqual([true, false]) + }) + + it('gives a request that joins a running delete that delete’s result', async () => { + const git = deferred<{ preservedBranch: { branchName: string; head: string } }>() + const run = vi.fn(() => git.promise) + void startBackgroundWorktreeRemoval({ removal, run, publish: () => {} }) + + const joined = finishAcceptedWorktreeRemoval( + { removing: true, warning: 'hook skipped' }, + removal.worktreeId + ) + git.resolve({ preservedBranch: { branchName: 'feature', head: 'abc' } }) + + await expect(joined).resolves.toEqual({ + warning: 'hook skipped', + preservedBranch: { branchName: 'feature', head: 'abc' } + }) + expect(run).toHaveBeenCalledTimes(1) + }) + + it('answers a request whose delete a concurrent removal of the same worktree replaced', async () => { + // The desktop IPC and runtime RPC removal paths coalesce separately, so both can be accepted. + const first = deferred>() + const second = deferred<{ preservedBranch: { branchName: string; head: string } }>() + const firstResult = startBackgroundWorktreeRemoval({ + removal, + run: () => first.promise, + publish: () => {} + }) + const secondResult = startBackgroundWorktreeRemoval({ + removal, + run: () => second.promise, + publish: () => {} + }) + + first.resolve({}) + await expect(firstResult).resolves.toEqual({}) + expect(isPending()).toBe(true) + + second.resolve({ preservedBranch: { branchName: 'feature', head: 'abc' } }) + await expect(secondResult).resolves.toEqual({ + preservedBranch: { branchName: 'feature', head: 'abc' } + }) + await _settlePendingWorktreeRemovalsForTests() + expect(isPending()).toBe(false) + }) + + it('rejects with the delete error and clears the row so a retry starts over', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const result = startBackgroundWorktreeRemoval({ + removal, + run: async () => { + throw new Error('Failed to delete worktree at /work/feature. Permission denied') + }, + publish: () => {} + }) + await expect(result).rejects.toThrow('Permission denied') + await _settlePendingWorktreeRemovalsForTests() + expect(isPending()).toBe(false) + }) + + it('keeps the table consistent when publishing throws', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + void startBackgroundWorktreeRemoval({ + removal, + run: async () => ({}), + publish: () => { + throw new Error('window gone') + } + }) + await _settlePendingWorktreeRemovalsForTests() + expect(isPending()).toBe(false) + }) + + it('refuses a create at the same path or branch while Git deletes', async () => { + const git = deferred>() + void startBackgroundWorktreeRemoval({ + removal, + run: () => git.promise, + publish: () => {} + }) + + expect(() => + assertNoPendingWorktreeRemovalConflict('/work/repo', { worktreePath: '/work/feature' }) + ).toThrow('Cleanup is pending; try again shortly.') + expect(() => + assertNoPendingWorktreeRemovalConflict('/work/repo', { branch: 'refs/heads/feature' }) + ).toThrow('Cleanup is pending; try again shortly.') + expect(() => + assertNoPendingWorktreeRemovalConflict('/work/other-repo', { branch: 'feature' }) + ).not.toThrow() + expect(() => + assertNoPendingWorktreeRemovalConflict('/work/repo', { + worktreePath: '/work/feature-2', + branch: 'feature-2' + }) + ).not.toThrow() + + git.resolve({}) + await _settlePendingWorktreeRemovalsForTests() + expect(() => + assertNoPendingWorktreeRemovalConflict('/work/repo', { worktreePath: '/work/feature' }) + ).not.toThrow() + }) + + it('answers only for this host: a same-id row on an SSH host is not being removed here', () => { + void startBackgroundWorktreeRemoval({ + removal, + run: () => new Promise(() => {}), + publish: () => {} + }) + expect(isPending('local')).toBe(true) + expect(isPending('ssh:box')).toBe(false) + }) + + it('marks rows for clients that read the marker and omits them for clients that do not', () => { + void startBackgroundWorktreeRemoval({ + removal, + run: () => new Promise(() => {}), + publish: () => {} + }) + const rows = [ + { id: removal.worktreeId, hostId: 'local' as const }, + { id: 'repo-1::/work/other' }, + { id: removal.worktreeId, hostId: 'ssh:box' as const } + ] + + expect( + projectPendingWorktreeRemovals(rows, (row) => row.id, true, snapshotPendingWorktreeRemovals()) + ).toEqual([ + { id: removal.worktreeId, hostId: 'local', removing: true }, + { id: 'repo-1::/work/other' }, + { id: removal.worktreeId, hostId: 'ssh:box' } + ]) + expect( + projectPendingWorktreeRemovals( + rows, + (row) => row.id, + false, + snapshotPendingWorktreeRemovals() + ) + ).toEqual([{ id: 'repo-1::/work/other' }, { id: removal.worktreeId, hostId: 'ssh:box' }]) + }) + + it('drops a row a listing read before Git finished deleting it, and keeps one whose delete failed', async () => { + const other = { ...removal, worktreeId: 'repo-1::/work/other' } + const removed = deferred>() + const failed = deferred>() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + void startBackgroundWorktreeRemoval({ removal, run: () => removed.promise, publish: () => {} }) + void startBackgroundWorktreeRemoval({ + removal: other, + run: async () => { + await failed.promise + throw new Error('git failed') + }, + publish: () => {} + }) + // The listing reads Git while both deletes run and replies after both ended. + const pendingAtScan = snapshotPendingWorktreeRemovals() + removed.resolve({}) + failed.resolve({}) + await _settlePendingWorktreeRemovalsForTests() + const rows: { id: string; hostId?: undefined }[] = [ + { id: removal.worktreeId }, + { id: other.worktreeId } + ] + + for (const clientReadsMarker of [true, false]) { + expect( + projectPendingWorktreeRemovals(rows, (row) => row.id, clientReadsMarker, pendingAtScan) + ).toEqual([{ id: other.worktreeId }]) + } + // A listing that began after the delete finished reads Git's current answer as-is. + expect( + projectPendingWorktreeRemovals(rows, (row) => row.id, true, snapshotPendingWorktreeRemovals()) + ).toBe(rows) + }) + + it('keeps WSL checkouts on the inline delete', () => { + expect(removesInBackground('/work/feature', {})).toBe(true) + expect(removesInBackground('/home/me/feature', { wslDistro: 'Ubuntu' })).toBe(false) + }) + + it('returns listings untouched when nothing is being removed', () => { + const rows: { id: string; hostId?: undefined }[] = [{ id: removal.worktreeId }] + expect( + projectPendingWorktreeRemovals( + rows, + (row) => row.id, + false, + snapshotPendingWorktreeRemovals() + ) + ).toBe(rows) + }) +}) diff --git a/src/main/worktree-background-removal.ts b/src/main/worktree-background-removal.ts new file mode 100644 index 000000000000..be6bb365d5ae --- /dev/null +++ b/src/main/worktree-background-removal.ts @@ -0,0 +1,364 @@ +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../shared/execution-host' +import type { RemoveWorktreeResult } from '../shared/worktree/create-types' +import type { GitWorktreeInfo } from '../shared/worktree/types' +import { normalizeLocalBranchRef } from './git/worktree-operation-options' +import { areWorktreePathsEqual } from './git/worktree-path-comparison' +import { acquireWatcherRemovalGate, type WatcherRemovalGate } from './ipc/watcher-removal-gate' +import { parseWslPath } from './wsl' +import { + readWorktreeRemovalRecords, + writeWorktreeRemovalRecords, + type WorktreeRemovalRecord +} from './worktree-removal-records' + +export type BackgroundWorktreeRemovalJob = { + /** `stopSignal` aborts on an orderly quit; pass it only to the checkout delete. */ + run: (stopSignal: AbortSignal) => Promise + /** Fires when the row starts showing as removing, and again after it has left the table. */ + publish: () => void +} + +/** The removals pending when a listing began to read Git. */ +export type PendingWorktreeRemovals = ReadonlyMap + +type RemovalSettlement = { + result: Promise + resolve: (result: RemoveWorktreeResult) => void + reject: (error: unknown) => void +} + +// The accepted removals, mirrored to disk on every change; listings and joins read only this. +const pendingByWorktreeId = new Map() +const jobsByWorktreeId = new Map>() +// What every request for a pending removal waits on: the first one and any that join it. +const settlementsByWorktreeId = new Map() +const stopControllers = new Set() +// Loaded removals' terminal/watcher fences, held until the resumed job takes its own gate. +const startupFencesByWorktreeId = new Map() +// Why weak: a listing that read Git before a delete finished holds the record until it replies. +const removedRecords = new WeakSet() +const NO_PENDING_REMOVALS: PendingWorktreeRemovals = new Map() +let recordsDirectory: string | null = null + +/** + * Loads removals a quit or crash interrupted, so listings mark them before the first paint and + * session restore cannot open a terminal or watcher in a half-deleted checkout before the resume. + */ +export async function loadWorktreeRemovalRecords(directory: string): Promise { + recordsDirectory = directory + for (const record of await readWorktreeRemovalRecords(directory)) { + if (!pendingByWorktreeId.has(record.worktreeId)) { + addPendingRemoval(record) + try { + startupFencesByWorktreeId.set( + record.worktreeId, + acquireWatcherRemovalGate(record.worktreePath) + ) + } catch { + // An overlapping loaded removal already fences this path. + } + } + } +} + +/** Hands a loaded removal's fence to its resumed job; call in the same tick the job takes its gate. */ +export function releaseStartupRemovalFence(worktreeId: string): void { + startupFencesByWorktreeId.get(worktreeId)?.release() + startupFencesByWorktreeId.delete(worktreeId) +} + +function addPendingRemoval(record: WorktreeRemovalRecord): RemovalSettlement { + let resolve!: RemovalSettlement['resolve'] + let reject!: RemovalSettlement['reject'] + const result = new Promise((settle, fail) => { + resolve = settle + reject = fail + }) + // Why: a removal nobody waits on (an older client's, or one a restart resumed) may still fail. + result.catch(() => {}) + const settlement = { result, resolve, reject } + pendingByWorktreeId.set(record.worktreeId, record) + settlementsByWorktreeId.set(record.worktreeId, settlement) + return settlement +} + +function persistRecords(): Promise { + if (!recordsDirectory) { + return Promise.resolve() + } + return writeWorktreeRemovalRecords(recordsDirectory, () => [ + ...pendingByWorktreeId.values() + ]).catch((error: unknown) => { + // Why: bookkeeping must not gate the delete; a lost write only costs resuming it after a quit. + console.warn('[worktrees] failed to persist worktree removal records', error) + }) +} + +/** + * The result of the removal this host is running for the worktree, for a request that joins it. + * Only this host's local checkouts are removed in the background. + */ +export function waitForPendingWorktreeRemoval( + worktreeId: string, + hostId?: ExecutionHostId +): Promise | undefined { + return (hostId ?? LOCAL_EXECUTION_HOST_ID) === LOCAL_EXECUTION_HOST_ID + ? settlementsByWorktreeId.get(worktreeId)?.result + : undefined +} + +/** Waits for the delete an accepted background removal started; the acceptance's fields ride along. */ +export async function finishAcceptedWorktreeRemoval( + accepted: T, + worktreeId: string, + hostId?: ExecutionHostId +): Promise> { + const { removing, ...acceptance } = accepted + const pending = removing ? waitForPendingWorktreeRemoval(worktreeId, hostId) : undefined + return pending ? { ...acceptance, ...(await pending) } : acceptance +} + +/** WSL checkouts still delete inline, as before; moving them off the request is a follow-up. */ +export function removesInBackground( + worktreePath: string, + options: { wslDistro?: string } +): boolean { + return !options.wslDistro && !parseWslPath(worktreePath) +} + +export function hasPendingWorktreeRemovals(): boolean { + return pendingByWorktreeId.size > 0 +} + +export function findPendingWorktreeRemovalConflict( + repoPath: string, + target: { worktreePath?: string; branch?: string } +): WorktreeRemovalRecord | undefined { + const branch = target.branch?.replace(/^refs\/heads\//, '') + for (const removal of pendingByWorktreeId.values()) { + if (!areWorktreePathsEqual(removal.repoPath, repoPath)) { + continue + } + if ( + (target.worktreePath && areWorktreePathsEqual(removal.worktreePath, target.worktreePath)) || + (branch && removal.branch === branch) + ) { + return removal + } + } + return undefined +} + +export function assertNoPendingWorktreeRemovalConflict( + repoPath: string, + target: { worktreePath?: string; branch?: string } +): void { + const removal = findPendingWorktreeRemovalConflict(repoPath, target) + if (removal) { + throw new Error( + `Orca is still deleting the workspace at ${removal.worktreePath}. Cleanup is pending; try again shortly.` + ) + } +} + +/** + * Records an accepted removal and runs its delete detached from the request that asked for it, so + * the delete finishes even when that request times out or its client goes away. Resolves with the + * delete's result. + */ +export function startBackgroundWorktreeRemoval( + args: { + removal: Pick< + WorktreeRemovalRecord, + 'worktreeId' | 'repoId' | 'repoPath' | 'deleteBranch' | 'force' + > & { worktree: Pick } + } & BackgroundWorktreeRemovalJob +): Promise { + const { worktree, ...accepted } = args.removal + const record: WorktreeRemovalRecord = { + ...accepted, + worktreePath: worktree.path, + branch: normalizeLocalBranchRef(worktree.branch), + head: worktree.head, + requestedAt: Date.now() + } + const settlement = addPendingRemoval(record) + runBackgroundWorktreeRemoval(record, args, persistRecords()) + publishSafely(args.publish) + return settlement.result +} + +/** Runs the same delete again for every record a quit or crash left without a running job. */ +export function resumeInterruptedWorktreeRemovals( + jobFor: (record: WorktreeRemovalRecord) => BackgroundWorktreeRemovalJob +): void { + for (const record of pendingByWorktreeId.values()) { + if (!jobsByWorktreeId.has(record.worktreeId)) { + runBackgroundWorktreeRemoval(record, jobFor(record), Promise.resolve()) + } + } +} + +/** Orderly quit: stops each checkout delete Git is running, without waiting for it to exit. */ +export function stopBackgroundWorktreeRemovals(): void { + for (const controller of stopControllers) { + controller.abort() + } +} + +function runBackgroundWorktreeRemoval( + record: WorktreeRemovalRecord, + job: BackgroundWorktreeRemovalJob, + recorded: Promise +): void { + const controller = new AbortController() + stopControllers.add(controller) + const settled: Promise = settleBackgroundWorktreeRemoval( + record, + settlementsByWorktreeId.get(record.worktreeId), + job, + recorded, + controller.signal + ).finally(() => { + stopControllers.delete(controller) + if (jobsByWorktreeId.get(record.worktreeId) === settled) { + jobsByWorktreeId.delete(record.worktreeId) + } + }) + jobsByWorktreeId.set(record.worktreeId, settled) +} + +async function settleBackgroundWorktreeRemoval( + record: WorktreeRemovalRecord, + settlement: RemovalSettlement | undefined, + job: BackgroundWorktreeRemovalJob, + recorded: Promise, + stopSignal: AbortSignal +): Promise { + await waitForRecordWrite(record, recorded) + let settle: (settlement: RemovalSettlement) => void + try { + if (stopSignal.aborted) { + return + } + const result = await job.run(stopSignal) + removedRecords.add(record) + settle = (settlement) => settlement.resolve(result) + } catch (error) { + if (stopSignal.aborted) { + // Why: quit stopped Git; the record stays so the next start finishes this delete. + return + } + console.warn(`[worktrees] background removal of ${record.worktreePath} failed`, error) + settle = (settlement) => settlement.reject(error) + } finally { + // A resumed job that ended before taking its own gate still holds the fence loading gave it. + releaseStartupRemovalFence(record.worktreeId) + } + // Why clear on failure too: the row returns live and retryable instead of retrying unseen. + const cleared = pendingByWorktreeId.get(record.worktreeId) === record + if (cleared) { + pendingByWorktreeId.delete(record.worktreeId) + settlementsByWorktreeId.delete(record.worktreeId) + } + // Why this run's own settlement: desktop IPC and runtime RPC coalesce separately, so a concurrent + // removal can replace the record, and the request waiting on this delete must still get its reply. + if (settlement) { + settle(settlement) + } + // Why notify before the clear is on disk: the clear is bookkeeping; a crash before it lands only + // re-runs a finish that re-derives what is left from Git. + publishSafely(job.publish) + if (cleared) { + await persistRecords() + } +} + +// Why: the record should be on disk before Git deletes, so a quit resumes the delete, but a disk or +// file pool stall must not hold the user's delete; past this, a crash only loses the resume. +const RECORD_WRITE_WAIT_MS = 2_000 + +async function waitForRecordWrite( + record: WorktreeRemovalRecord, + recorded: Promise +): Promise { + let timer: ReturnType | undefined + const timedOut = await Promise.race([ + recorded.then(() => false), + new Promise((resolve) => { + timer = setTimeout(() => resolve(true), RECORD_WRITE_WAIT_MS) + }) + ]) + clearTimeout(timer) + if (timedOut) { + console.warn( + `[worktrees] removal record for ${record.worktreePath} not on disk after ${RECORD_WRITE_WAIT_MS} ms; deleting anyway` + ) + } +} + +function publishSafely(publish: () => void): void { + try { + publish() + } catch (error) { + // Why: a failed notification must not strand the table entry or reject the detached job. + console.error('[worktrees] failed to publish background removal state', error) + } +} + +/** Taken before a listing reads Git; pass it to projectPendingWorktreeRemovals with the rows. */ +export function snapshotPendingWorktreeRemovals(): PendingWorktreeRemovals { + return pendingByWorktreeId.size === 0 ? NO_PENDING_REMOVALS : new Map(pendingByWorktreeId) +} + +/** + * Marks rows whose checkout this host is deleting, or leaves them out for a client that cannot + * read the marker: such a client already dropped the row on acceptance and would re-show it. + */ +export function projectPendingWorktreeRemovals< + T extends { hostId?: ExecutionHostId; removing?: true } +>( + rows: T[], + idOf: (row: T) => string, + clientReadsMarker: boolean, + pendingAtScan: PendingWorktreeRemovals +): T[] { + if (pendingByWorktreeId.size === 0 && pendingAtScan.size === 0) { + return rows + } + const projected: T[] = [] + for (const row of rows) { + const id = idOf(row) + const local = row.hostId === undefined || row.hostId === LOCAL_EXECUTION_HOST_ID + if (local && pendingByWorktreeId.has(id)) { + if (clientReadsMarker) { + projected.push({ ...row, removing: true }) + } + continue + } + const scanned = local ? pendingAtScan.get(id) : undefined + // Why: Git was read before this delete finished; unmarked, the gone row reads as a failed delete. + if (!scanned || !removedRecords.has(scanned)) { + projected.push(row) + } + } + return projected +} + +export async function _settlePendingWorktreeRemovalsForTests(): Promise { + while (jobsByWorktreeId.size > 0) { + await Promise.all(jobsByWorktreeId.values()) + } +} + +export function _resetPendingWorktreeRemovalsForTests(): void { + pendingByWorktreeId.clear() + jobsByWorktreeId.clear() + settlementsByWorktreeId.clear() + stopControllers.clear() + for (const fence of startupFencesByWorktreeId.values()) { + fence.release() + } + startupFencesByWorktreeId.clear() + recordsDirectory = null +} diff --git a/src/main/worktree-removal-acceptance-queue.ts b/src/main/worktree-removal-acceptance-queue.ts new file mode 100644 index 000000000000..e7248b0b7a05 --- /dev/null +++ b/src/main/worktree-removal-acceptance-queue.ts @@ -0,0 +1,15 @@ +import { runKeyedSerializedOperation } from './cli/keyed-promise-queue' + +const acceptanceQueueByRepo = new Map>() + +/** + * Runs one local removal's archive hook, preflight and teardown only after the previous removal in + * the same repo was accepted, whichever client asked: hooks that write refs race the repo's ref + * locks (#2259). Git's checkout delete runs after acceptance, in parallel under its own limit. + */ +export function runSerializedWorktreeRemovalAcceptance( + repoPath: string, + accept: () => Promise +): Promise { + return runKeyedSerializedOperation(acceptanceQueueByRepo, repoPath, accept) +} diff --git a/src/main/worktree-removal-records.ts b/src/main/worktree-removal-records.ts new file mode 100644 index 000000000000..1fd3558d232a --- /dev/null +++ b/src/main/worktree-removal-records.ts @@ -0,0 +1,93 @@ +// Why a sidecar beside the profile state: the record must be on disk before Git starts deleting, +// and profile-state writes are batched; the sidecar writer fsyncs and renames atomically. It also +// keeps a host-local, in-flight obligation out of profile exports and backups. +import { + readSidecarSnapshot, + sidecarSnapshotFile, + withSidecarSnapshotQueue, + writeSidecarSnapshot +} from './sidecar-snapshot-file' + +const RECORDS_FILE_NAME = 'orca-worktree-removals.json' +const RECORDS_VERSION = 1 + +/** A local worktree removal Orca accepted; everything a restart needs to run the same delete. */ +export type WorktreeRemovalRecord = { + worktreeId: string + repoId: string + repoPath: string + worktreePath: string + /** Short branch name the checkout had; empty when detached. */ + branch: string + /** Branch head when the removal was accepted; empty when unknown. */ + head: string + deleteBranch: boolean + force: boolean + requestedAt: number +} + +type PersistedWorktreeRemovalRecords = { + version: number + removals: WorktreeRemovalRecord[] +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function parseRecord(value: unknown): WorktreeRemovalRecord | null { + if ( + !isRecord(value) || + typeof value.worktreeId !== 'string' || + typeof value.repoId !== 'string' || + typeof value.repoPath !== 'string' || + typeof value.worktreePath !== 'string' || + typeof value.branch !== 'string' || + typeof value.head !== 'string' || + typeof value.deleteBranch !== 'boolean' || + typeof value.force !== 'boolean' || + typeof value.requestedAt !== 'number' + ) { + return null + } + return { + worktreeId: value.worktreeId, + repoId: value.repoId, + repoPath: value.repoPath, + worktreePath: value.worktreePath, + branch: value.branch, + head: value.head, + deleteBranch: value.deleteBranch, + force: value.force, + requestedAt: value.requestedAt + } +} + +export function worktreeRemovalRecordsFile(directory: string): string { + return sidecarSnapshotFile(directory, RECORDS_FILE_NAME) +} + +/** Records a previous run left behind; a missing or unreadable file reads as none. */ +export async function readWorktreeRemovalRecords( + directory: string +): Promise { + const parsed = await readSidecarSnapshot(worktreeRemovalRecordsFile(directory)) + if (!isRecord(parsed) || parsed.version !== RECORDS_VERSION || !Array.isArray(parsed.removals)) { + return [] + } + return parsed.removals.flatMap((entry) => parseRecord(entry) ?? []) +} + +/** Writes the current records; `current` is read when the queued write runs, so the latest wins. */ +export function writeWorktreeRemovalRecords( + directory: string, + current: () => WorktreeRemovalRecord[] +): Promise { + const file = worktreeRemovalRecordsFile(directory) + return withSidecarSnapshotQueue(file, () => + writeSidecarSnapshot(file, { + version: RECORDS_VERSION, + removals: current() + } satisfies PersistedWorktreeRemovalRecords) + ) +} diff --git a/src/main/worktree-removal-safety.test.ts b/src/main/worktree-removal-safety.test.ts index d32289a5fc76..9aa9775de44d 100644 --- a/src/main/worktree-removal-safety.test.ts +++ b/src/main/worktree-removal-safety.test.ts @@ -6,8 +6,7 @@ import { canSafelyRemoveOrphanedWorktreeDirectory, findRegisteredDeletableWorktree, getRegisteredDeletableWorktree, - isDangerousWorktreeRemovalPath, - isWorktreePathMissing + isDangerousWorktreeRemovalPath } from './worktree-removal-safety' import { CLIENT_REMOVAL_HOME, executionHostRemovalHome } from './worktree-removal-home-guard' @@ -350,16 +349,6 @@ describe('canSafelyRemoveOrphanedWorktreeDirectory', () => { }) }) -describe('isWorktreePathMissing', () => { - it('does not treat an unreadable path as absent', async () => { - await expect( - isWorktreePathMissing('/workspaces/locked', async () => { - throw Object.assign(new Error('permission denied'), { code: 'EACCES' }) - }) - ).resolves.toBe(false) - }) -}) - describe('canCleanupUnregisteredOrcaLeftoverDirectory', () => { const repo = { path: '/repos/main' } const ownedMeta = { orcaCreatedAt: 1, orcaCreationSource: 'runtime' as const } diff --git a/src/main/worktree-retirement-backfill-stall.test.ts b/src/main/worktree-retirement-backfill-stall.test.ts index 490696949c9e..33f6a7612917 100644 --- a/src/main/worktree-retirement-backfill-stall.test.ts +++ b/src/main/worktree-retirement-backfill-stall.test.ts @@ -11,20 +11,7 @@ vi.mock('node:fs/promises', async (importOriginal) => ({ })) const { ensureRetiredWorktreeNamesBackfilled } = await import('./worktree-name-retirement') -const { RETIREMENT_BACKFILL_RETRY_AFTER_FAILURE_MS, RETIREMENT_BACKFILL_SCAN_TIMEOUT_MS } = - await import('./worktree-retirement-backfill-scan') - -/** A `readdir` the test releases by hand, standing in for a wedged NFS/SMB/WSL mount. */ -function stalledReaddir(): { install: () => void; release: () => void } { - let release: () => void = () => {} - return { - install: () => - readdirMock.mockImplementation( - () => new Promise((_resolve, reject) => (release = () => reject(new Error('EIO')))) - ), - release: () => release() - } -} +const { RETIREMENT_BACKFILL_SCAN_TIMEOUT_MS } = await import('./worktree-retirement-backfill-scan') const repo = { id: 'repo-a', @@ -66,42 +53,4 @@ describe('retirement backfill against a stalled listing', () => { await settled }) - - it('recovers the namespace once the mount comes back, instead of staying wedged until restart', async () => { - const stall = stalledReaddir() - stall.install() - const store = backfillStore() - - const stalled = ensureRetiredWorktreeNamesBackfilled(store, repo, settings) - const settled = expect(stalled).rejects.toThrow(/exceeded/) - await vi.advanceTimersByTimeAsync(RETIREMENT_BACKFILL_SCAN_TIMEOUT_MS) - await settled - - // The mount comes back: the kernel releases the abandoned call, which is what frees the - // namespace to be listed again. Retrying while it is still stuck would only stack a second one. - stall.release() - readdirMock.mockResolvedValue([{ name: 'nautilus', isDirectory: () => true }]) - await vi.advanceTimersByTimeAsync(RETIREMENT_BACKFILL_RETRY_AFTER_FAILURE_MS) - - await ensureRetiredWorktreeNamesBackfilled(store, repo, settings) - expect(store.merged).toContain('nautilus') - }) - - it('does not re-probe a mount whose previous listing is still stuck', async () => { - stalledReaddir().install() - const store = backfillStore() - - const stalled = ensureRetiredWorktreeNamesBackfilled(store, repo, settings) - const settled = expect(stalled).rejects.toThrow(/exceeded/) - await vi.advanceTimersByTimeAsync(RETIREMENT_BACKFILL_SCAN_TIMEOUT_MS) - await settled - - const callsWhileStuck = readdirMock.mock.calls.length - await vi.advanceTimersByTimeAsync(RETIREMENT_BACKFILL_RETRY_AFTER_FAILURE_MS * 5) - await expect(ensureRetiredWorktreeNamesBackfilled(store, repo, settings)).rejects.toThrow( - /exceeded/ - ) - - expect(readdirMock.mock.calls.length).toBe(callsWhileStuck) - }) }) diff --git a/src/main/worktree-trash.test.ts b/src/main/worktree-trash.test.ts index 2819c789bf5f..4d5c163e5bf7 100644 --- a/src/main/worktree-trash.test.ts +++ b/src/main/worktree-trash.test.ts @@ -1,18 +1,12 @@ import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { Repo } from '../shared/repo-types' import { collectWorktreeTrashSweepRoots, - getWorktreeTrashRoot, - isWorktreeTrashEntryName, - moveWorktreeDirectoryToTrash, - restoreWorktreeDirectoryFromTrash, - scheduleWorktreeTrashDeletion, sweepStaleWorktreeTrash, - whenWorktreeTrashDeletionsSettled, WORKTREE_TRASH_DIR_NAME } from './worktree-trash' @@ -23,7 +17,6 @@ beforeEach(async () => { }) afterEach(async () => { - await whenWorktreeTrashDeletionsSettled() await rm(scratchDir, { recursive: true, force: true }) }) @@ -32,112 +25,6 @@ async function createWorktreeDirectory(worktreePath: string): Promise { await writeFile(join(worktreePath, 'node_modules', 'pkg', 'index.js'), 'module.exports = 1\n') } -describe('moveWorktreeDirectoryToTrash', () => { - it('renames the checkout into a hidden sibling trash root', async () => { - const worktreePath = join(scratchDir, 'repo', 'feature') - await createWorktreeDirectory(worktreePath) - - const trashPath = await moveWorktreeDirectoryToTrash(worktreePath) - - expect(trashPath).toBeDefined() - expect(existsSync(worktreePath)).toBe(false) - expect(trashPath!.startsWith(join(scratchDir, 'repo', WORKTREE_TRASH_DIR_NAME))).toBe(true) - expect(existsSync(join(trashPath!, 'node_modules', 'pkg', 'index.js'))).toBe(true) - }) - - it('leaves a file target untouched without creating a trash root', async () => { - const worktreePath = join(scratchDir, '.git') - await writeFile(worktreePath, 'gitdir: /preserved/admin\n') - - expect(await moveWorktreeDirectoryToTrash(worktreePath)).toBeUndefined() - expect(await readFile(worktreePath, 'utf8')).toBe('gitdir: /preserved/admin\n') - expect(existsSync(getWorktreeTrashRoot(worktreePath))).toBe(false) - }) - - it('leaves a directory symlink and its target untouched', async () => { - const target = join(scratchDir, 'target') - const worktreePath = join(scratchDir, 'link') - await createWorktreeDirectory(target) - await symlink(target, worktreePath, process.platform === 'win32' ? 'junction' : 'dir') - - expect(await moveWorktreeDirectoryToTrash(worktreePath)).toBeUndefined() - expect(existsSync(join(worktreePath, 'node_modules', 'pkg', 'index.js'))).toBe(true) - expect(existsSync(getWorktreeTrashRoot(worktreePath))).toBe(false) - }) - - it('generates sweepable, collision-free entry names', async () => { - const first = await moveWorktreeDirectoryToTrash(await seededWorktree('one')) - const second = await moveWorktreeDirectoryToTrash(await seededWorktree('two')) - - expect(first).not.toEqual(second) - expect(await readdir(join(scratchDir, 'repo', WORKTREE_TRASH_DIR_NAME))).toHaveLength(2) - for (const entry of await readdir(join(scratchDir, 'repo', WORKTREE_TRASH_DIR_NAME))) { - expect(isWorktreeTrashEntryName(entry)).toBe(true) - } - }) - - it('reports the rename as unavailable and leaves no trash root when the checkout is missing', async () => { - const worktreePath = join(scratchDir, 'repo', 'gone') - - expect(await moveWorktreeDirectoryToTrash(worktreePath)).toBeUndefined() - expect(existsSync(getWorktreeTrashRoot(worktreePath))).toBe(false) - }) - - it.skipIf(process.platform === 'win32')('refuses a symlinked trash root', async () => { - const worktreePath = join(scratchDir, 'repo', 'feature') - const externalRoot = join(scratchDir, 'external') - await createWorktreeDirectory(worktreePath) - await mkdir(externalRoot) - await symlink(externalRoot, getWorktreeTrashRoot(worktreePath)) - - expect(await moveWorktreeDirectoryToTrash(worktreePath)).toBeUndefined() - expect(existsSync(worktreePath)).toBe(true) - expect(await readdir(externalRoot)).toEqual([]) - }) - - async function seededWorktree(name: string): Promise { - const worktreePath = join(scratchDir, 'repo', name) - await createWorktreeDirectory(worktreePath) - return worktreePath - } -}) - -describe('restoreWorktreeDirectoryFromTrash', () => { - it('puts the checkout back where Git registered it', async () => { - const worktreePath = join(scratchDir, 'repo', 'feature') - await createWorktreeDirectory(worktreePath) - const trashPath = await moveWorktreeDirectoryToTrash(worktreePath) - - expect(await restoreWorktreeDirectoryFromTrash(trashPath!, worktreePath)).toBe(true) - expect(existsSync(join(worktreePath, 'node_modules', 'pkg', 'index.js'))).toBe(true) - expect(existsSync(trashPath!)).toBe(false) - }) - - it('reports failure instead of throwing when the trashed path is gone', async () => { - const trashRoot = getWorktreeTrashRoot(join(scratchDir, 'repo', 'feature')) - expect( - await restoreWorktreeDirectoryFromTrash( - join(trashRoot, 'wt-1-abcdef01'), - join(scratchDir, 'repo', 'feature') - ) - ).toBe(false) - }) -}) - -describe('scheduleWorktreeTrashDeletion', () => { - it('deletes trashed checkouts in the background', async () => { - const worktreePath = join(scratchDir, 'repo', 'feature') - await createWorktreeDirectory(worktreePath) - const trashPath = await moveWorktreeDirectoryToTrash(worktreePath) - - scheduleWorktreeTrashDeletion(trashPath!) - await whenWorktreeTrashDeletionsSettled() - - expect(existsSync(trashPath!)).toBe(false) - expect(existsSync(getWorktreeTrashRoot(worktreePath))).toBe(true) - }) -}) - describe('sweepStaleWorktreeTrash', () => { it('removes leftover entries from both workspace layouts and nothing else', async () => { const nestedTrashRoot = join(scratchDir, 'repo', WORKTREE_TRASH_DIR_NAME) @@ -158,6 +45,9 @@ describe('sweepStaleWorktreeTrash', () => { expect(existsSync(join(nestedTrashRoot, 'unrelated-directory'))).toBe(true) expect(existsSync(join(nestedTrashRoot, 'wt-notes.txt'))).toBe(true) expect(existsSync(join(liveWorktree, 'node_modules', 'pkg', 'index.js'))).toBe(true) + // Nothing refills a trash root, so an emptied one goes; one still holding foreign entries stays. + expect(existsSync(flatTrashRoot)).toBe(false) + expect(existsSync(nestedTrashRoot)).toBe(true) }) it('ignores workspace roots that do not exist', async () => { diff --git a/src/main/worktree-trash.ts b/src/main/worktree-trash.ts index cb4d3d52e03b..088c51bfbc9e 100644 --- a/src/main/worktree-trash.ts +++ b/src/main/worktree-trash.ts @@ -1,10 +1,9 @@ -// Why: `git worktree remove` deletes the whole checkout (usually a multi-GB node_modules) inside the -// remove IPC, so the UI sat on a spinner for 8-35s. Renaming the directory into a sibling trash root -// is a metadata operation, and the recursive delete then runs after the IPC has already returned. +// Why: releases before worktree removal ran inline renamed checkouts into a sibling trash root and +// deleted them in the background; a quit mid-delete left entries behind. Nothing creates trash now, +// so this sweep only drains those legacy entries. -import { randomBytes } from 'node:crypto' -import { lstat, mkdir, readdir, rename, rmdir } from 'node:fs/promises' -import { dirname, join } from 'node:path' +import { lstat, readdir, rmdir } from 'node:fs/promises' +import { join } from 'node:path' import { removeHostTree } from './host-tree-removal' import { isFolderRepo } from '../shared/repo-kind' import { computeWorkspaceRoot, getWorktreePathSettings } from './ipc/worktree-logic' @@ -14,93 +13,13 @@ import { parseWslPath } from './wsl' export const WORKTREE_TRASH_DIR_NAME = '.orca-worktree-trash' -// `-`: the nonce keeps concurrent removals of same-named worktrees from colliding. +// `-`: the only names the retired rename ever generated. const TRASH_ENTRY_PATTERN = /^wt-\d+-[0-9a-f]{8}$/ // Why: the sweep must stay cheap on a workspace root holding many repo containers. const TRASH_SWEEP_MAX_CONTAINERS = 200 -/** Trash root for a worktree: a hidden sibling, so the rename always stays on one volume. */ -export function getWorktreeTrashRoot(worktreePath: string): string { - return join(dirname(worktreePath), WORKTREE_TRASH_DIR_NAME) -} - -export function isWorktreeTrashEntryName(entryName: string): boolean { - return TRASH_ENTRY_PATTERN.test(entryName) -} - -/** - * Move a worktree directory aside so the caller can return before it is deleted. - * Returns the trash path, or `undefined` when the rename is unavailable (a different - * volume, or Windows open handles) and the caller must delete in place instead. - */ -export async function moveWorktreeDirectoryToTrash( - worktreePath: string -): Promise { - const trashRoot = getWorktreeTrashRoot(worktreePath) - const trashPath = join(trashRoot, `wt-${Date.now()}-${randomBytes(4).toString('hex')}`) - try { - // A malformed Git registration can name the checkout's .git file. - const worktreeStat = await lstat(worktreePath) - if (!worktreeStat.isDirectory() || worktreeStat.isSymbolicLink()) { - return undefined - } - await mkdir(trashRoot, { recursive: true }) - const trashRootStat = await lstat(trashRoot) - if (!trashRootStat.isDirectory() || trashRootStat.isSymbolicLink()) { - throw new Error(`Refusing non-directory worktree trash root: ${trashRoot}`) - } - await rename(worktreePath, trashPath) - return trashPath - } catch (error) { - console.warn( - `[worktrees] Deferred deletion unavailable for ${worktreePath}; deleting in place`, - error - ) - // Leave no empty trash root behind when the rename never happened; rmdir keeps queued entries. - await rmdir(trashRoot).catch(() => {}) - return undefined - } -} - -/** Undo a trash rename so a failed registration cleanup leaves the worktree exactly as it was. */ -export async function restoreWorktreeDirectoryFromTrash( - trashPath: string, - worktreePath: string -): Promise { - try { - await rename(trashPath, worktreePath) - return true - } catch (error) { - console.warn(`[worktrees] Failed to restore ${worktreePath} from ${trashPath}`, error) - return false - } -} - -// Why serialized: one background delete at a time keeps a burst of removals from saturating disk I/O -// while the user keeps working. -let queuedTrashDeletions: Promise = Promise.resolve() - -export function scheduleWorktreeTrashDeletion(trashPath: string): void { - queuedTrashDeletions = queuedTrashDeletions.then(async () => { - try { - await removeHostTree(trashPath) - } catch (error) { - // Why only a warning: the directory is already invisible to the user, and the startup sweep retries it. - console.warn(`[worktrees] Failed to delete trashed worktree at ${trashPath}`, error) - } - }) -} - -/** Test/shutdown hook: resolves once every queued background deletion has settled. */ -export function whenWorktreeTrashDeletionsSettled(): Promise { - return queuedTrashDeletions -} - -/** - * Delete trash entries left behind by a previous run (a crash or a kill during background deletion). - * Only entries matching the generated name pattern inside a trash root are removed. - */ +/** Delete legacy trash entries. Only entries matching the generated name pattern inside a trash root are removed. */ export async function sweepStaleWorktreeTrash( workspaceRoots: readonly string[] ): Promise<{ removed: number }> { @@ -117,7 +36,7 @@ export async function sweepStaleWorktreeTrash( continue } for (const entry of entries) { - if (!isWorktreeTrashEntryName(entry)) { + if (!TRASH_ENTRY_PATTERN.test(entry)) { continue } try { @@ -130,6 +49,8 @@ export async function sweepStaleWorktreeTrash( ) } } + // Why: nothing refills the root, so drop it once empty; rmdir keeps anything still inside. + await rmdir(trashRoot).catch(() => {}) } if (removed > 0) { console.log(`[worktrees] Swept ${removed} leftover worktree director(ies) from a previous run`) @@ -158,7 +79,7 @@ async function collectExistingTrashRoots(workspaceRoots: readonly string[]): Pro return [...trashRoots] } -/** Workspace roots of local git repos — the only places Orca creates worktree trash. */ +/** Workspace roots of local git repos — the only places Orca ever created worktree trash. */ export function collectWorktreeTrashSweepRoots( repos: readonly Repo[], settings: Pick diff --git a/src/preload/api/agent-status-api.ts b/src/preload/api/agent-status-api.ts index 909f0fd20fba..119a3c55f6c8 100644 --- a/src/preload/api/agent-status-api.ts +++ b/src/preload/api/agent-status-api.ts @@ -18,6 +18,8 @@ export type AgentStatusApi = { inferInterrupt: (request: AgentInterruptInferenceRequest) => Promise /** Guarded clear for an answered AskUserQuestion wait — the CLI emits no hook at answer time, so the renderer reports the submit keystroke. */ inferQuestionAnswered: (request: AgentQuestionAnsweredInferenceRequest) => Promise + /** Whether the host can check this pane's agent process; without that, silence keeps today's cleanup. */ + hasVerifiableAgentProcess?: (paneKey: string) => Promise /** Listen for PTYs on a legacy numeric pane key that have registry-backed UUID pane proof. */ onMigrationUnsupported: (callback: (entry: MigrationUnsupportedPtyEntry) => void) => () => void onMigrationUnsupportedClear: (callback: (data: { ptyId: string }) => void) => () => void diff --git a/src/preload/api/agent-status-bridge.ts b/src/preload/api/agent-status-bridge.ts index 5b3566a87113..1a056252634e 100644 --- a/src/preload/api/agent-status-bridge.ts +++ b/src/preload/api/agent-status-bridge.ts @@ -28,6 +28,8 @@ export const agentStatusApi = { ipcRenderer.invoke('agentStatus:getSnapshot'), inferInterrupt: (request: AgentInterruptInferenceRequest): Promise => ipcRenderer.invoke('agentStatus:inferInterrupt', request), + hasVerifiableAgentProcess: (paneKey: string): Promise => + ipcRenderer.invoke('agentStatus:hasVerifiableAgentProcess', paneKey), inferQuestionAnswered: (request: AgentQuestionAnsweredInferenceRequest): Promise => ipcRenderer.invoke('agentStatus:inferQuestionAnswered', request), onMigrationUnsupported: ( diff --git a/src/preload/api/browser-api.ts b/src/preload/api/browser-api.ts index 460cabe8ee4d..bb29fe115233 100644 --- a/src/preload/api/browser-api.ts +++ b/src/preload/api/browser-api.ts @@ -21,7 +21,8 @@ import type { BrowserCaptureSelectionScreenshotArgs, BrowserCaptureSelectionScreenshotResult, BrowserExtractHoverArgs, - BrowserExtractHoverResult + BrowserExtractHoverResult, + GrabIntent } from '../../shared/browser-grab-types' import type { BrowserContextMenuDismissedEvent, @@ -136,7 +137,7 @@ export type BrowserApi = { args: BrowserCaptureSelectionScreenshotArgs ) => Promise extractHoverPayload: (args: BrowserExtractHoverArgs) => Promise - onGrabModeToggle: (callback: (browserPageId: string) => void) => () => void + onGrabModeToggle: (callback: (browserPageId: string, intent: GrabIntent) => void) => () => void onGrabActionShortcut: ( callback: (args: { browserPageId: string; key: 'c' | 's' }) => void ) => () => void diff --git a/src/preload/api/browser-bridge-page-interaction-and-sessions.ts b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts index 319207175a3d..822c0f54025a 100644 --- a/src/preload/api/browser-bridge-page-interaction-and-sessions.ts +++ b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts @@ -1,6 +1,7 @@ import { ipcRenderer } from 'electron' import type { PreloadApi } from '../api-types' import type { BrowserUserAgentMode } from '../../shared/browser-user-agent-mode' +import type { GrabIntent } from '../../shared/browser-grab-types' export const browserPageInteractionAndSessionsApi = { onContextMenuRequested: ( @@ -105,9 +106,14 @@ export const browserPageInteractionAndSessionsApi = { }) => ipcRenderer.invoke('browser:captureSelectionScreenshot', args), extractHoverPayload: (args: { browserPageId: string }) => ipcRenderer.invoke('browser:extractHoverPayload', args), - onGrabModeToggle: (callback: (browserPageId: string) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, browserPageId: string) => - callback(browserPageId) + onGrabModeToggle: ( + callback: (browserPageId: string, intent: GrabIntent) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + browserPageId: string, + intent: GrabIntent + ) => callback(browserPageId, intent) ipcRenderer.on('browser:grabModeToggle', listener) return () => ipcRenderer.removeListener('browser:grabModeToggle', listener) }, diff --git a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts index 456adfef27a3..51957a5eec5a 100644 --- a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts +++ b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts @@ -10,6 +10,7 @@ import { type RichMarkdownContextMenuTableTarget } from '../../shared/rich-markdown-context-menu' import type { NativeFileDropPayload } from '../../shared/native-file-drop' +import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import type { TerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target' import type { ClipboardImageThumbnail } from '../../shared/clipboard-image' import type { ReadClipboardTextOptions } from '../../shared/clipboard-text' @@ -24,6 +25,7 @@ export const uiClipboardAndWindowControlsApi = { relativePath: string staged: boolean runtimeEnvironmentId?: string + navigation?: RuntimeNavigationTarget }) => void ): (() => void) => { const listener = ( @@ -34,6 +36,7 @@ export const uiClipboardAndWindowControlsApi = { relativePath: string staged: boolean runtimeEnvironmentId?: string + navigation?: RuntimeNavigationTarget } ) => callback(data) ipcRenderer.on('ui:openDiffFromMobile', listener) diff --git a/src/preload/api/ui-bridge-terminal-and-session-tabs.ts b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts index a331ab61219d..f49e7e65e499 100644 --- a/src/preload/api/ui-bridge-terminal-and-session-tabs.ts +++ b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts @@ -6,6 +6,7 @@ import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { TuiAgent } from '../../shared/tui-agent' +import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import type { RuntimeMobileSessionTabMove, RuntimeTerminalCreateRequestPayload, @@ -203,6 +204,7 @@ export const uiTerminalAndSessionTabsApi = { filePath: string relativePath: string runtimeEnvironmentId?: string + navigation?: RuntimeNavigationTarget }) => void ): (() => void) => { const listener = ( @@ -212,6 +214,7 @@ export const uiTerminalAndSessionTabsApi = { filePath: string relativePath: string runtimeEnvironmentId?: string + navigation?: RuntimeNavigationTarget } ) => callback(data) ipcRenderer.on('ui:openFileFromMobile', listener) diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index f6bb247bf91e..217e5c027e53 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -1,6 +1,7 @@ import type { MarkdownDocument } from '../../shared/filesystem-entry-types' import type { PersistedUIState } from '../../shared/persisted-ui-state-types' import type { TuiAgent } from '../../shared/tui-agent' +import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import type { TerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target' import type { WorktreeDefaultTabsLaunch, @@ -224,6 +225,7 @@ export type UiCommandEventApi = { filePath: string relativePath: string runtimeEnvironmentId?: string + navigation?: RuntimeNavigationTarget }) => void ) => () => void onOpenDiffFromMobile: ( @@ -233,6 +235,7 @@ export type UiCommandEventApi = { relativePath: string staged: boolean runtimeEnvironmentId?: string + navigation?: RuntimeNavigationTarget }) => void ) => () => void onMobileMarkdownRequest: (callback: (request: RuntimeMobileMarkdownRequest) => void) => () => void diff --git a/src/relay/agent-hook-envelope-build.ts b/src/relay/agent-hook-envelope-build.ts index 6b1b45432d8b..ba2779c684a1 100644 --- a/src/relay/agent-hook-envelope-build.ts +++ b/src/relay/agent-hook-envelope-build.ts @@ -16,6 +16,7 @@ export function buildRelayHookEnvelope( ): AgentHookRelayEnvelope { return { source, + agentPresence: event.agentPresence, paneKey: event.paneKey, ...(event.launchToken ? { launchToken: event.launchToken } : {}), tabId: event.tabId, diff --git a/src/relay/agent-hook-request.ts b/src/relay/agent-hook-request.ts new file mode 100644 index 000000000000..e852f66cf69b --- /dev/null +++ b/src/relay/agent-hook-request.ts @@ -0,0 +1,86 @@ +import type { IncomingMessage, ServerResponse } from 'node:http' +import { HOOK_REQUEST_SLOWLORIS_MS } from '../shared/agent-hook-listener/listener-limits' +import { normalizeHookPayload } from '../shared/agent-hook-listener' +import { mergeAgentHookRequestHeaders } from '../shared/agent-hook-listener/hook-envelope' +import { readRequestBody } from '../shared/agent-hook-listener/request-body' +import { resolveHookSource } from '../shared/agent-hook-listener/source-routing' +import type { createHookTransportInterferenceTracker } from '../shared/agent-hook-transport-interference' +import { isHookRequestTruncatedError } from '../shared/agent-hook-transport-interference' +import type { HookListenerState } from '../shared/agent-hook-listener/listener-state' +import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' +import type { AgentHookSource } from '../shared/agent-hook-relay' +import type { AgentHookResultRetryScheduler } from './agent-hook-result-retry-scheduler' +import { hookBodyEnv, hookBodyVersion } from './agent-hook-envelope-build' + +export async function handleRelayHookRequest( + req: IncomingMessage, + res: ServerResponse, + options: { + token: string + env: string + state: HookListenerState + applyEvent: ( + event: AgentHookEventPayload, + source: AgentHookSource, + env?: string, + version?: string + ) => AgentHookEventPayload | undefined + retryScheduler: AgentHookResultRetryScheduler + transportInterference: ReturnType + } +): Promise { + if (req.method !== 'POST') { + res.writeHead(404) + res.end() + return + } + if (req.headers['x-orca-agent-hook-token'] !== options.token) { + res.writeHead(403) + res.end() + return + } + // Why: track our own destroy so the slowloris cap can't be misread as outside interference. + let destroyedBySlowlorisCap = false + req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => { + destroyedBySlowlorisCap = true + req.destroy() + }) + try { + const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname + const source = resolveHookSource(pathname) + if (!source) { + res.writeHead(404) + res.end() + return + } + const body = await readRequestBody(req) + const hookBody = mergeAgentHookRequestHeaders(body, req.headers) + const event = normalizeHookPayload(options.state, source, hookBody, options.env, { + deferCompactOwnershipToClient: true + }) + if (event) { + // TODO: once normalizeHookPayload returns validated env/version, drop bodyEnv/bodyVersion and source them from the listener result. + const env = hookBodyEnv(hookBody) + const version = hookBodyVersion(hookBody) + const stored = options.applyEvent(event, source, env, version) + if (stored) { + options.retryScheduler.scheduleAssistantMessageRetry(source, hookBody, stored, env, version) + options.retryScheduler.scheduleTranscriptPoll(source, hookBody, stored, env, version) + } + } + res.writeHead(204) + res.end() + } catch (err) { + // Why (#11217): a remote host can run the same IDS; count truncations here so a blocked SSH + // relay reports the cause instead of an anonymous "hook request failed". + if (isHookRequestTruncatedError(err) && !destroyedBySlowlorisCap) { + options.transportInterference.record({ source: null, error: err }) + } + // Why: hooks fail open (204 on any error) so a buggy agent never blocks the run; still log so the 204 doesn't mask bugs. + process.stderr.write( + `[relay-hook-server] hook request failed: ${err instanceof Error ? err.message : String(err)}\n` + ) + res.writeHead(204) + res.end() + } +} diff --git a/src/relay/agent-hook-retired-pane-suppression.test.ts b/src/relay/agent-hook-retired-pane-suppression.test.ts index ab48893f76db..23285c9e3d1b 100644 --- a/src/relay/agent-hook-retired-pane-suppression.test.ts +++ b/src/relay/agent-hook-retired-pane-suppression.test.ts @@ -162,8 +162,10 @@ describe('RelayAgentHookRuntime wiring', () => { it('routes hook admission through the PTY handler and drops the cache on retirement', async () => { const retired = new RetiredPaneSurfaceRegistry() const surfaceRetiredListeners: PtySurfaceRetiredListener[] = [] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this stand-in implements every PtyHandler member the hook runtime registers or reads. const ptyHandler = { addEnvAugmenter: vi.fn(), + setAgentPresenceTrigger: vi.fn(), setExitListener: vi.fn(), setSurfaceRetiredListener: vi.fn((listener: PtySurfaceRetiredListener | null) => { if (listener) { diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index e75b11a24505..126d0470abb0 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -1,3 +1,7 @@ +import { handleRelayHookRequest } from './agent-hook-request' +import { transitionHookPresence } from '../shared/agent-hook-presence-transition' +import { RelayAgentPresence } from './relay-agent-presence' +import { isSameAgentProcess } from '../shared/agent-process-presence' import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' import { randomUUID } from 'node:crypto' import { join } from 'node:path' @@ -17,16 +21,11 @@ import { getEndpointFileName, writeEndpointFile } from '../shared/agent-hook-listener/endpoint-publication' -import { HOOK_REQUEST_SLOWLORIS_MS } from '../shared/agent-hook-listener/listener-limits' import { normalizeHookPayload } from '../shared/agent-hook-listener' -import { mergeAgentHookRequestHeaders } from '../shared/agent-hook-listener/hook-envelope' -import { readRequestBody } from '../shared/agent-hook-listener/request-body' -import { resolveHookSource } from '../shared/agent-hook-listener/source-routing' import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' import { createHookTransportInterferenceTracker, - describeHookTransportInterference, - isHookRequestTruncatedError + describeHookTransportInterference } from '../shared/agent-hook-transport-interference' import { isAgentHookSource, @@ -68,7 +67,6 @@ export type RelayHookServerOptions = { export type RelayHookServerStartOptions = { publishEndpoint?: boolean } - export class RelayAgentHookServer { private server: ReturnType | null = null private port = 0 @@ -92,6 +90,7 @@ export class RelayAgentHookServer { private fixedToken: string | undefined private preferredPort: number private portFallbackApplied = false + private readonly presenceChecks = new RelayAgentPresence() private retryScheduler: AgentHookResultRetryScheduler constructor(options: RelayHookServerOptions) { @@ -107,7 +106,7 @@ export class RelayAgentHookServer { env: this.env, isListening: () => this.server !== null, applyEvent: (event, source, env, version) => { - this.applyEvent(event, source, env, version) + this.applyEvent(event, source, env, version, { checkPresence: false }) } }) } @@ -215,6 +214,7 @@ export class RelayAgentHookServer { dropPane: (paneKey) => this.clearPaneState(paneKey) }) for (const { event, meta } of replayable) { + void this.checkAgentPresence(event.paneKey) this.forward( buildRelayHookEnvelope(event, meta.source, meta.env, meta.version, { isReplay: true }) ) @@ -222,6 +222,20 @@ export class RelayAgentHookServer { return replayable.length } + checkAgentPresence(paneKey: string): Promise { + const row = this.state.lastStatusByPaneKey.get(paneKey) + const meta = this.lastEnvelopeMetaByPaneKey.get(paneKey) + return this.presenceChecks.check( + row, + () => this.state.lastStatusByPaneKey.get(paneKey), + (event) => { + if (meta) { + this.applyEvent(event, meta.source, meta.env, meta.version) + } + } + ) + } + /** Drop a paneKey's cached entries on PTY exit so a terminated pane can't resurface as a ghost event on reconnect. */ clearPaneState(paneKey: string): void { this.retryScheduler.clearAssistantMessageRetry(paneKey) @@ -249,74 +263,40 @@ export class RelayAgentHookServer { // ─── Private ────────────────────────────────────────────────────── private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { - if (req.method !== 'POST') { - res.writeHead(404) - res.end() - return - } - if (req.headers['x-orca-agent-hook-token'] !== this.token) { - res.writeHead(403) - res.end() - return - } - // Why: track our own destroy so the slowloris cap can't be misread as outside interference. - let destroyedBySlowlorisCap = false - req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => { - destroyedBySlowlorisCap = true - req.destroy() + await handleRelayHookRequest(req, res, { + token: this.token, + env: this.env, + state: this.state, + applyEvent: (event, source, env, version) => this.applyEvent(event, source, env, version), + retryScheduler: this.retryScheduler, + transportInterference: this.transportInterference }) - try { - const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname - const source = resolveHookSource(pathname) - if (!source) { - res.writeHead(404) - res.end() - return - } - const body = await readRequestBody(req) - const hookBody = mergeAgentHookRequestHeaders(body, req.headers) - const event = normalizeHookPayload(this.state, source, hookBody, this.env, { - deferCompactOwnershipToClient: true - }) - if (event) { - // TODO: once normalizeHookPayload returns validated env/version, drop bodyEnv/bodyVersion and source them from the listener result. - const env = hookBodyEnv(hookBody) - const version = hookBodyVersion(hookBody) - this.applyEvent(event, source, env, version) - this.retryScheduler.scheduleAssistantMessageRetry(source, hookBody, event, env, version) - this.retryScheduler.scheduleTranscriptPoll(source, hookBody, event, env, version) - } - res.writeHead(204) - res.end() - } catch (err) { - // Why (#11217): a remote host can run the same IDS; count truncations here so a blocked SSH - // relay reports the cause instead of an anonymous "hook request failed". - if (isHookRequestTruncatedError(err) && !destroyedBySlowlorisCap) { - this.transportInterference.record({ source: null, error: err }) - } - // Why: hooks fail open (204 on any error) so a buggy agent never blocks the run; still log so the 204 doesn't mask bugs. - process.stderr.write( - `[relay-hook-server] hook request failed: ${err instanceof Error ? err.message : String(err)}\n` - ) - res.writeHead(204) - res.end() - } } private applyEvent( - event: AgentHookEventPayload, + incoming: AgentHookEventPayload, source: AgentHookSource, env?: string, version?: string, - options: { isReplay?: boolean } = {} - ): void { + options: { isReplay?: boolean; checkPresence?: boolean } = {} + ): AgentHookEventPayload | undefined { + const transitioned = transitionHookPresence( + incoming, + this.state.lastStatusByPaneKey.get(incoming.paneKey) + ) + if (!transitioned) { + return undefined + } + const event = transitioned.agentPresence?.ended + ? { ...transitioned, providerSessionOnly: true } + : transitioned // Why: this post came from a process still running inside a pane whose tab the user closed. // Caching or forwarding it makes every connected client advertise a live, resumable agent pane // that no tab owns — the advertisement that ends up auto-typing a second `--resume` onto a // transcript the orphan is still writing (#12447). Drop the stale cache with it. if (this.isPaneSurfaceRetired(event.paneKey)) { this.clearPaneState(event.paneKey) - return + return undefined } if (event.payload.state !== 'done' || event.payload.lastAssistantMessage) { this.retryScheduler.clearAssistantMessageRetry(event.paneKey) @@ -329,11 +309,25 @@ export class RelayAgentHookServer { this.clearPaneState(paneKey) ) ) { - return + return undefined } this.lastEnvelopeMetaByPaneKey.delete(event.paneKey) this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version }) this.forward(buildRelayHookEnvelope(event, source, env, version, options)) + const sender = incoming.agentPresence?.process + const owner = event.agentPresence + // Why: a live hook proves its own process alive; only another process's hook casts doubt on the owner. + if ( + options.checkPresence !== false && + sender && + owner?.process && + !owner.ended && + !isSameAgentProcess(sender, owner.process) + ) { + void this.checkAgentPresence(event.paneKey) + } + // Why: retries compare against the cached row by identity, so they must hold that exact row. + return this.state.lastStatusByPaneKey.get(event.paneKey) } private ingestSpoolRecord(record: SpoolRecord): void { diff --git a/src/relay/ai-vault-opencode-reader.ts b/src/relay/ai-vault-opencode-reader.ts index d90f2e08cd13..a4222a742bb3 100644 --- a/src/relay/ai-vault-opencode-reader.ts +++ b/src/relay/ai-vault-opencode-reader.ts @@ -8,7 +8,7 @@ import { parseOpenCodeSessionFile } from '../main/ai-vault/session-scanner-openc import { createOpenCodeSqliteProcessClient } from '../main/ai-vault/session-scanner-opencode-sqlite-process-client' import { buildRelayAiVaultServiceEnv } from '../main/ai-vault/session-scanner-service-env' import { resolveOpenCodeDataDirectory } from '../main/opencode/opencode-data-directory' -import SyncDatabase from '../main/sqlite/sync-database' +import SyncDatabase, { isSqliteAvailable } from '../main/sqlite/sync-database' type Reader = Pick & { dispose(): void } @@ -147,6 +147,10 @@ async function readRuntimeExecutable(path: string): Promise } function canCurrentRuntimeReadSqlite(): boolean { + // Why: same admission as the host probes, so Node 22.13–22.15 waits for the pinned runtime. + if (!isSqliteAvailable()) { + return false + } let db: SyncDatabase | undefined try { db = new SyncDatabase(':memory:') diff --git a/src/relay/git-branch-delete-refusal-parity.test.ts b/src/relay/git-branch-delete-refusal-parity.test.ts index 71344bca940c..b71cb20d4f43 100644 --- a/src/relay/git-branch-delete-refusal-parity.test.ts +++ b/src/relay/git-branch-delete-refusal-parity.test.ts @@ -13,18 +13,9 @@ import type * as FsPromises from 'node:fs/promises' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { gitExecFileAsyncMock, resolveGitDirMock, moveWorktreeDirectoryToTrashMock } = vi.hoisted( - () => ({ - gitExecFileAsyncMock: vi.fn(), - resolveGitDirMock: vi.fn(), - moveWorktreeDirectoryToTrashMock: vi.fn() - }) -) - -vi.mock('../main/worktree-trash', () => ({ - moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock, - restoreWorktreeDirectoryFromTrash: vi.fn(async () => true), - scheduleWorktreeTrashDeletion: vi.fn() +const { gitExecFileAsyncMock, resolveGitDirMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn(), + resolveGitDirMock: vi.fn() })) vi.mock('../main/git/runner', () => ({ @@ -170,9 +161,6 @@ beforeEach(() => { gitExecFileAsyncMock.mockReset() resolveGitDirMock.mockReset() resolveGitDirMock.mockImplementation(async (worktreePath: string) => `${worktreePath}/.git`) - moveWorktreeDirectoryToTrashMock.mockReset() - // Default: the checkout cannot be renamed aside, so removal runs `worktree remove` in place. - moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined) }) describe('relay/desktop branch-delete refusal parity', () => { diff --git a/src/relay/git-handler-local-base-ref-refresh.ts b/src/relay/git-handler-local-base-ref-refresh.ts index 299780799f0b..3dd9b7db302d 100644 --- a/src/relay/git-handler-local-base-ref-refresh.ts +++ b/src/relay/git-handler-local-base-ref-refresh.ts @@ -1,88 +1,66 @@ import type { GitExec } from './git-handler-ops' -import { areRelayWorktreePathsEqual, readRelayWorktreeList } from './git-handler-worktree-ops' +import { readRelayWorktreeList } from './git-handler-worktree-ops' import type { GitCapabilityCache } from '../shared/git-capability-cache' +import { createCoalescingKeyedRunner } from '../shared/coalescing-keyed-runner' +import { + fastForwardLocalBaseBranch, + inspectLocalBaseBranch, + type LocalBaseBranchFastForwardOutcome, + type LocalBaseBranchGit, + type LocalBaseBranchInspection, + type LocalBaseBranchRefs +} from '../shared/worktree/local-base-branch-fast-forward' + +// Why: the host owns this; one run per branch at a time here also serializes creates from every client of this relay. +const runPerLocalBaseBranch = createCoalescingKeyedRunner() export async function refreshLocalBaseRefForWorktreeCreateOp( git: GitExec, params: Record, capabilities: GitCapabilityCache -): Promise { - const repoPath = params.repoPath as string - const fullRef = params.fullRef as string - const remoteTrackingRef = params.remoteTrackingRef as string - const ownerWorktreePath = params.ownerWorktreePath as string | undefined - const checkOnly = params.checkOnly === true +): Promise { + const refs = await readLocalBaseBranchRefs(git, params) + const host = relayLocalBaseBranchGit(git, capabilities) + return runPerLocalBaseBranch(`${refs.repoPath}\0${refs.fullRef}`, refs.remoteTrackingRef, () => + fastForwardLocalBaseBranch(host, refs) + ) +} + +export async function inspectLocalBaseRefForWorktreeCreateOp( + git: GitExec, + params: Record, + capabilities: GitCapabilityCache +): Promise { + const refs = await readLocalBaseBranchRefs(git, params) + return inspectLocalBaseBranch(relayLocalBaseBranchGit(git, capabilities), refs) +} +async function readLocalBaseBranchRefs( + git: GitExec, + params: Record +): Promise { + const { repoPath, fullRef, remoteTrackingRef } = params if ( typeof repoPath !== 'string' || typeof fullRef !== 'string' || - typeof remoteTrackingRef !== 'string' || - (ownerWorktreePath !== undefined && typeof ownerWorktreePath !== 'string') + typeof remoteTrackingRef !== 'string' ) { throw new Error('Invalid local base ref refresh request.') } if (!fullRef.startsWith('refs/heads/') || !remoteTrackingRef.startsWith('refs/remotes/')) { throw new Error('Invalid local base ref refresh refs.') } - await git(['check-ref-format', fullRef], repoPath) await git(['check-ref-format', remoteTrackingRef], repoPath) - - const localOid = await revParseCommit(git, repoPath, fullRef, 'Local base ref is missing.') - const remoteOid = await revParseCommit( - git, - repoPath, - remoteTrackingRef, - 'Remote-tracking base ref is missing.' - ) - - // Why: this RPC mutates refs/worktrees, so the relay repeats main-process - // safety checks at mutation time to close stale-preflight and direct-call gaps. - try { - await git(['merge-base', '--is-ancestor', localOid, remoteOid], repoPath) - } catch { - throw new Error('Local base ref is not a fast-forward update.') - } - - const worktrees = await readRelayWorktreeList(git, repoPath, capabilities) - const ownerWorktree = worktrees.find((worktree) => worktree.branch === fullRef) - if (ownerWorktree) { - if (ownerWorktreePath && !areRelayWorktreePathsEqual(ownerWorktree.path, ownerWorktreePath)) { - throw new Error('Local base ref is checked out in a different worktree.') - } - const { stdout } = await git( - ['status', '--porcelain', '--untracked-files=no'], - ownerWorktree.path - ) - if (stdout.trim()) { - throw new Error('Local base ref worktree has tracked changes.') - } - if (checkOnly) { - return - } - await git(['reset', '--hard', remoteOid], ownerWorktree.path) - return - } - - // Why: not checked out anywhere — fast-forward the bare ref. The - // expected-old-OID form is a no-op-safe compare-and-swap if the ref moved - // since the caller's evaluation snapshot. - if (checkOnly) { - return - } - await git(['update-ref', fullRef, remoteOid, localOid], repoPath) + return { repoPath, fullRef, remoteTrackingRef } } -async function revParseCommit( +function relayLocalBaseBranchGit( git: GitExec, - repoPath: string, - ref: string, - missingMessage: string -): Promise { - const { stdout } = await git(['rev-parse', '--verify', `${ref}^{commit}`], repoPath) - const oid = stdout.trim() - if (!oid) { - throw new Error(missingMessage) + capabilities: GitCapabilityCache +): LocalBaseBranchGit { + return { + exec: (args, cwd) => git(args, cwd), + listWorktrees: (repoPath) => readRelayWorktreeList(git, repoPath, capabilities) } - return oid } diff --git a/src/relay/git-handler-registration.ts b/src/relay/git-handler-registration.ts index 6462327c416d..02690b202c8b 100644 --- a/src/relay/git-handler-registration.ts +++ b/src/relay/git-handler-registration.ts @@ -65,6 +65,9 @@ export function registerGitHandlers( dispatcher.onRequest('git.refreshLocalBaseRefForWorktreeCreate', (p) => handlers.worktree.refreshLocalBaseRefForWorktreeCreate(p) ) + dispatcher.onRequest('git.inspectLocalBaseRefForWorktreeCreate', (p) => + handlers.worktree.inspectLocalBaseRefForWorktreeCreate(p) + ) dispatcher.onRequest('git.markRemoteOrcaCreated', (p) => handlers.exec.markRemoteOrcaCreated(p)) dispatcher.onRequest('git.renameCurrentBranch', (p) => handlers.exec.renameCurrentBranch(p)) dispatcher.onRequest('git.forceDeletePreservedBranch', (p) => diff --git a/src/relay/git-handler-worktree-operations.ts b/src/relay/git-handler-worktree-operations.ts index 966030c4562f..9303db719649 100644 --- a/src/relay/git-handler-worktree-operations.ts +++ b/src/relay/git-handler-worktree-operations.ts @@ -12,7 +12,10 @@ import { worktreeIsCleanOp } from './git-handler-worktree-ops' import { annotatePrunableWorktreesByExistence } from './git-handler-worktree-list' -import { refreshLocalBaseRefForWorktreeCreateOp } from './git-handler-local-base-ref-refresh' +import { + inspectLocalBaseRefForWorktreeCreateOp, + refreshLocalBaseRefForWorktreeCreateOp +} from './git-handler-local-base-ref-refresh' import { hasUnsupportedRevParsePathFormatEcho, isUnsupportedRevParsePathFormatError @@ -171,4 +174,8 @@ export class GitHandlerWorktreeOperations extends GitHandlerOperationContext { refreshLocalBaseRefForWorktreeCreateOp(this.git.bind(this), params, this.gitCapabilities) ) } + + async inspectLocalBaseRefForWorktreeCreate(params: Record) { + return inspectLocalBaseRefForWorktreeCreateOp(this.git.bind(this), params, this.gitCapabilities) + } } diff --git a/src/relay/git-handler-worktree-provisioning.test.ts b/src/relay/git-handler-worktree-provisioning.test.ts index 99e9122abf92..9764f9b8b9b4 100644 --- a/src/relay/git-handler-worktree-provisioning.test.ts +++ b/src/relay/git-handler-worktree-provisioning.test.ts @@ -20,7 +20,8 @@ import { import { createGitHandlerRelay, createGitTempDir, - removeGitTempDir + removeGitTempDir, + type GitSpyTarget } from './git-handler-test-harness' describe('GitHandler', () => { @@ -79,251 +80,208 @@ describe('GitHandler', () => { return { localDispatcher, gitMock } } - it('resets the owning worktree to the remote-tracking ref', async () => { + function revParse(ref: string): string { + return execFileSync('git', ['rev-parse', ref], { cwd: tmpDir, encoding: 'utf-8' }).trim() + } + + // The checked-out branch is one commit behind refs/remotes/origin/main, which changes base.txt. + function initBehindRepo(): { branchRef: string; localSha: string; remoteSha: string } { gitInit(tmpDir) + execFileSync('git', ['config', 'core.autocrlf', 'false'], { cwd: tmpDir, stdio: 'pipe' }) writeFileSync(path.join(tmpDir, 'base.txt'), 'base') gitCommit(tmpDir, 'initial') - const branchRef = currentBranchFullRef(tmpDir) - const ownerPath = reportedWorktreePath(tmpDir) - const firstSha = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() + const localSha = revParse('HEAD') writeFileSync(path.join(tmpDir, 'base.txt'), 'remote') gitCommit(tmpDir, 'remote update') - const remoteSha = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() + const remoteSha = revParse('HEAD') execFileSync('git', ['update-ref', 'refs/remotes/origin/main', remoteSha], { cwd: tmpDir, stdio: 'pipe' }) - execFileSync('git', ['reset', '--hard', firstSha], { cwd: tmpDir, stdio: 'pipe' }) + execFileSync('git', ['reset', '--hard', localSha], { cwd: tmpDir, stdio: 'pipe' }) + return { branchRef: currentBranchFullRef(tmpDir), localSha, remoteSha } + } - await dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { - repoPath: tmpDir, - fullRef: branchRef, - remoteTrackingRef: 'refs/remotes/origin/main', - ownerWorktreePath: ownerPath - }) + it('fast-forwards the owning worktree to the remote-tracking ref on the host', async () => { + const { branchRef, remoteSha } = initBehindRepo() - const actual = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - expect(actual).toBe(remoteSha) + await expect( + dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { + repoPath: tmpDir, + fullRef: branchRef, + remoteTrackingRef: 'refs/remotes/origin/main' + }) + ).resolves.toEqual({ status: 'updated', ownerWorktreePath: reportedWorktreePath(tmpDir) }) + + expect(revParse('HEAD')).toBe(remoteSha) await expect(fs.readFile(path.join(tmpDir, 'base.txt'), 'utf-8')).resolves.toBe('remote') }) it('fast-forwards a non-checked-out local branch via update-ref', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'base.txt'), 'base') - gitCommit(tmpDir, 'initial') - execFileSync('git', ['branch', 'main-copy'], { cwd: tmpDir, stdio: 'pipe' }) - writeFileSync(path.join(tmpDir, 'base.txt'), 'remote') - gitCommit(tmpDir, 'remote update') - const remoteSha = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - execFileSync('git', ['update-ref', 'refs/remotes/origin/main', remoteSha], { - cwd: tmpDir, - stdio: 'pipe' - }) + const { localSha, remoteSha } = initBehindRepo() + execFileSync('git', ['branch', 'main-copy', localSha], { cwd: tmpDir, stdio: 'pipe' }) - await dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { - repoPath: tmpDir, - fullRef: 'refs/heads/main-copy', - remoteTrackingRef: 'refs/remotes/origin/main' - }) + await expect( + dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { + repoPath: tmpDir, + fullRef: 'refs/heads/main-copy', + remoteTrackingRef: 'refs/remotes/origin/main' + }) + ).resolves.toEqual({ status: 'updated' }) - // No working tree owns main-copy, so the bare ref fast-forwards. - const actual = execFileSync('git', ['rev-parse', 'refs/heads/main-copy'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - expect(actual).toBe(remoteSha) + expect(revParse('refs/heads/main-copy')).toBe(remoteSha) }) - it('does not move a non-checked-out local branch when checkOnly is set', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'base.txt'), 'base') - gitCommit(tmpDir, 'initial') - execFileSync('git', ['branch', 'main-copy'], { cwd: tmpDir, stdio: 'pipe' }) - const originalSha = execFileSync('git', ['rev-parse', 'refs/heads/main-copy'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - writeFileSync(path.join(tmpDir, 'base.txt'), 'remote') - gitCommit(tmpDir, 'remote update') - const remoteSha = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - execFileSync('git', ['update-ref', 'refs/remotes/origin/main', remoteSha], { - cwd: tmpDir, - stdio: 'pipe' - }) + it('reports nothing to do for a local branch that does not exist yet', async () => { + initBehindRepo() - await dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { - repoPath: tmpDir, - fullRef: 'refs/heads/main-copy', - remoteTrackingRef: 'refs/remotes/origin/main', - checkOnly: true - }) + await expect( + dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { + repoPath: tmpDir, + fullRef: 'refs/heads/not-created-yet', + remoteTrackingRef: 'refs/remotes/origin/main' + }) + ).resolves.toEqual({ status: 'nothing_to_do' }) + }) - const actual = execFileSync('git', ['rev-parse', 'refs/heads/main-copy'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - expect(actual).toBe(originalSha) + it.each([ + [ + 'refs outside heads/remotes', + { fullRef: 'refs/tags/main', remoteTrackingRef: 'refs/remotes/origin/main' }, + 'Invalid local base ref refresh refs.' + ], + [ + 'a non-string ref', + { fullRef: 42, remoteTrackingRef: 'refs/remotes/origin/main' }, + 'Invalid local base ref refresh request.' + ], + [ + 'a missing remote-tracking ref', + { fullRef: 'refs/heads/main' }, + 'Invalid local base ref refresh request.' + ] + ])('rejects %s without touching git', async (_case, params, message) => { + const { localDispatcher, gitMock } = setupMockedRefreshHandler() + + for (const method of [ + 'git.refreshLocalBaseRefForWorktreeCreate', + 'git.inspectLocalBaseRefForWorktreeCreate' + ]) { + await expect( + localDispatcher.callRequest(method, { repoPath: '/repo', ...params }) + ).rejects.toThrow(message) + } + expect(gitMock).not.toHaveBeenCalled() }) - it('rejects invalid local base ref refresh refs', async () => { + it('rejects a ref name git itself refuses', async () => { gitInit(tmpDir) await expect( dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { repoPath: tmpDir, - fullRef: 'refs/tags/main', + fullRef: 'refs/heads/bad..name', remoteTrackingRef: 'refs/remotes/origin/main' }) - ).rejects.toThrow('Invalid local base ref refresh refs.') + ).rejects.toThrow() }) - it('rejects a dirty owner worktree before resetting', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'base.txt'), 'base') - gitCommit(tmpDir, 'initial') - const branchRef = currentBranchFullRef(tmpDir) - const ownerPath = reportedWorktreePath(tmpDir) - const firstSha = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - writeFileSync(path.join(tmpDir, 'base.txt'), 'remote') - gitCommit(tmpDir, 'remote update') - const remoteSha = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - execFileSync('git', ['update-ref', 'refs/remotes/origin/main', remoteSha], { - cwd: tmpDir, - stdio: 'pipe' - }) - execFileSync('git', ['reset', '--hard', firstSha], { cwd: tmpDir, stdio: 'pipe' }) + it('leaves a dirty owner worktree and its edit alone', async () => { + const { branchRef, localSha } = initBehindRepo() writeFileSync(path.join(tmpDir, 'base.txt'), 'local dirty') await expect( dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { repoPath: tmpDir, fullRef: branchRef, - remoteTrackingRef: 'refs/remotes/origin/main', - ownerWorktreePath: ownerPath + remoteTrackingRef: 'refs/remotes/origin/main' }) - ).rejects.toThrow('Local base ref worktree has tracked changes.') + ).resolves.toEqual({ + status: 'skipped_dirty_worktree', + ownerWorktreePath: reportedWorktreePath(tmpDir) + }) - const actual = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - expect(actual).toBe(firstSha) + expect(revParse('HEAD')).toBe(localSha) await expect(fs.readFile(path.join(tmpDir, 'base.txt'), 'utf-8')).resolves.toBe('local dirty') }) - it('rejects when the caller-supplied owner path is not the checked-out branch owner', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'base.txt'), 'base') - gitCommit(tmpDir, 'initial') - const branchRef = currentBranchFullRef(tmpDir) - const headSha = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - execFileSync('git', ['update-ref', 'refs/remotes/origin/main', headSha], { - cwd: tmpDir, - stdio: 'pipe' - }) + it('does not move diverged local refs', async () => { + const { localSha } = initBehindRepo() + execFileSync('git', ['checkout', '-q', '-b', 'main-copy', localSha], { cwd: tmpDir }) + writeFileSync(path.join(tmpDir, 'local.txt'), 'local') + gitCommit(tmpDir, 'local update') + const divergedSha = revParse('refs/heads/main-copy') await expect( dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { repoPath: tmpDir, - fullRef: branchRef, - remoteTrackingRef: 'refs/remotes/origin/main', - ownerWorktreePath: path.join(path.dirname(tmpDir), 'different-owner') + fullRef: 'refs/heads/main-copy', + remoteTrackingRef: 'refs/remotes/origin/main' }) - ).rejects.toThrow('Local base ref is checked out in a different worktree.') + ).resolves.toEqual({ status: 'skipped_not_fast_forward' }) + + expect(revParse('refs/heads/main-copy')).toBe(divergedSha) }) - it('rejects diverged local refs before mutating', async () => { - gitInit(tmpDir) - writeFileSync(path.join(tmpDir, 'base.txt'), 'base') - gitCommit(tmpDir, 'initial') - execFileSync('git', ['branch', 'main-copy'], { cwd: tmpDir, stdio: 'pipe' }) - writeFileSync(path.join(tmpDir, 'remote.txt'), 'remote') - gitCommit(tmpDir, 'remote update') - const remoteSha = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - execFileSync('git', ['update-ref', 'refs/remotes/origin/main', remoteSha], { - cwd: tmpDir, - stdio: 'pipe' - }) - execFileSync('git', ['checkout', 'main-copy'], { cwd: tmpDir, stdio: 'pipe' }) - writeFileSync(path.join(tmpDir, 'local.txt'), 'local') - gitCommit(tmpDir, 'local update') - const localSha = execFileSync('git', ['rev-parse', 'refs/heads/main-copy'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() + it('inspects how far local is behind without moving it', async () => { + const { branchRef, localSha, remoteSha } = initBehindRepo() await expect( - dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { + dispatcher.callRequest('git.inspectLocalBaseRefForWorktreeCreate', { repoPath: tmpDir, - fullRef: 'refs/heads/main-copy', - remoteTrackingRef: 'refs/remotes/origin/main', - ownerWorktreePath: tmpDir + fullRef: branchRef, + remoteTrackingRef: 'refs/remotes/origin/main' }) - ).rejects.toThrow('Local base ref is not a fast-forward update.') + ).resolves.toEqual({ + status: 'behind', + behind: 1, + localOid: localSha, + remoteOid: remoteSha, + ownerWorktreePath: reportedWorktreePath(tmpDir) + }) - const actual = execFileSync('git', ['rev-parse', 'refs/heads/main-copy'], { - cwd: tmpDir, - encoding: 'utf-8' - }).trim() - expect(actual).toBe(localSha) + expect(revParse('HEAD')).toBe(localSha) + await expect(fs.readFile(path.join(tmpDir, 'base.txt'), 'utf-8')).resolves.toBe('base') }) - it('resets owner worktree to captured remote OID without update-ref', async () => { - const { localDispatcher, gitMock } = setupMockedRefreshHandler() + function mockBehindOwnerGit( + gitMock: ReturnType['gitMock'], + onMerge: () => Promise = async () => {} + ) { + let localOid = 'old-local-oid' gitMock.mockImplementation(async (args: string[]) => { - if (args[0] === 'check-ref-format') { + const command = args.find((arg, index) => !arg.startsWith('-') && args[index - 1] !== '-c') + if (command === 'check-ref-format' || command === 'status') { return { stdout: '', stderr: '' } } - if (args[0] === 'rev-parse' && args[2] === 'refs/remotes/origin/main^{commit}') { - return { stdout: 'remote-oid\n', stderr: '' } - } - if (args[0] === 'rev-parse') { - return { stdout: 'old-local-oid\n', stderr: '' } + if (command === 'rev-parse') { + const oid = args[2] === 'refs/remotes/origin/main^{commit}' ? 'remote-oid' : localOid + return { stdout: `${oid}\n`, stderr: '' } } - if (args[0] === 'merge-base') { - return { stdout: '', stderr: '' } + if (command === 'rev-list') { + return { stdout: '0\t2\n', stderr: '' } } - if (args[0] === 'worktree') { + if (command === 'worktree') { return { - stdout: 'worktree /repo\nHEAD old-local-oid\nbranch refs/heads/main\n', + stdout: 'worktree /repo\0HEAD old-local-oid\0branch refs/heads/main\0\0', stderr: '' } } - if (args[0] === 'status') { - return { stdout: '', stderr: '' } + if (command === 'symbolic-ref') { + return { stdout: 'refs/heads/main\n', stderr: '' } } - if (args[0] === 'reset') { + if (command === 'merge') { + await onMerge() + localOid = 'remote-oid' return { stdout: '', stderr: '' } } throw new Error(`unexpected git call: ${args.join(' ')}`) }) + } + + it('fast-forwards the owner with merge --ff-only and hooks disabled, never reset or update-ref', async () => { + const { localDispatcher, gitMock } = setupMockedRefreshHandler() + mockBehindOwnerGit(gitMock) await expect( localDispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { @@ -331,40 +289,172 @@ describe('GitHandler', () => { fullRef: 'refs/heads/main', remoteTrackingRef: 'refs/remotes/origin/main' }) - ).resolves.toBeUndefined() - - expect(gitMock).toHaveBeenCalledWith( - ['merge-base', '--is-ancestor', 'old-local-oid', 'remote-oid'], - '/repo' + ).resolves.toEqual({ status: 'updated', ownerWorktreePath: '/repo' }) + + const merge = gitMock.mock.calls.find(([args]) => args.includes('merge')) + expect(merge?.[0]).toEqual( + expect.arrayContaining([ + 'core.hooksPath=/dev/null', + 'branch.main.mergeOptions=', + '--ff-only', + 'recursive', + '--no-verify-signatures', + 'remote-oid' + ]) ) - expect(gitMock).toHaveBeenCalledWith(['reset', '--hard', 'remote-oid'], '/repo') - expect(gitMock.mock.calls.map((call) => call[0])).not.toContainEqual([ - 'update-ref', - 'refs/heads/main', - 'remote-oid', - 'old-local-oid' - ]) + expect(merge?.[1]).toBe('/repo') + const commands = gitMock.mock.calls.map(([args]) => args[0]) + expect(commands).not.toContain('reset') + expect(commands).not.toContain('update-ref') }) - it('fails closed when worktree ownership cannot be listed', async () => { + it('runs one refresh per branch at a time for every client of the relay', async () => { const { localDispatcher, gitMock } = setupMockedRefreshHandler() - gitMock.mockImplementation(async (args: string[]) => { - if (args[0] === 'check-ref-format') { - return { stdout: '', stderr: '' } + let releaseMerge!: () => void + const mergeHeld = new Promise((resolve) => (releaseMerge = resolve)) + mockBehindOwnerGit(gitMock, () => mergeHeld) + const params = { + repoPath: '/repo', + fullRef: 'refs/heads/main', + remoteTrackingRef: 'refs/remotes/origin/main' + } + + const first = localDispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', params) + await vi.waitFor(() => + expect(gitMock.mock.calls.some(([args]) => args.includes('merge'))).toBe(true) + ) + const second = localDispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', params) + // Let the second request get past validation and as far as it can while the merge is held. + await vi.waitFor(() => + expect(gitMock.mock.calls.filter(([args]) => args[0] === 'check-ref-format').length).toBe(4) + ) + await new Promise((resolve) => setTimeout(resolve, 20)) + releaseMerge() + + await expect(first).resolves.toEqual({ status: 'updated', ownerWorktreePath: '/repo' }) + // The joiner's follow-up run finds local already at the target. + await expect(second).resolves.toEqual({ status: 'nothing_to_do' }) + expect(gitMock.mock.calls.filter(([args]) => args.includes('merge'))).toHaveLength(1) + }) + + // A fork on the real host: the checked-out branch behind origin/main, one commit behind upstream/main. + function initForkRepo(): { branchRef: string; upstreamSha: string } { + const { branchRef, localSha, remoteSha } = initBehindRepo() + execFileSync('git', ['checkout', '-q', '-b', 'fork-upstream', remoteSha], { cwd: tmpDir }) + writeFileSync(path.join(tmpDir, 'base.txt'), 'upstream') + gitCommit(tmpDir, 'upstream update') + const upstreamSha = revParse('HEAD') + execFileSync('git', ['checkout', '-q', '-'], { cwd: tmpDir }) + execFileSync('git', ['update-ref', 'refs/remotes/upstream/main', upstreamSha], { + cwd: tmpDir + }) + execFileSync('git', ['branch', '-q', '-D', 'fork-upstream'], { cwd: tmpDir }) + expect(revParse('HEAD')).toBe(localSha) + return { branchRef, upstreamSha } + } + + /** Sends refreshes in order, each reaching the relay's per-branch queue while the first merge is held. */ + async function refreshWhileFirstMergeHeld(branchRef: string, remotes: string[]) { + const { dispatcher: relay, handler } = createGitHandlerRelay() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: GitHandler really has this private git runner; the spy wraps the real one. + const target = handler as unknown as GitSpyTarget + const realGit = target.git.bind(handler) + let releaseMerge!: () => void + const mergeHeld = new Promise((resolve) => (releaseMerge = resolve)) + let merges = 0 + let activeMerges = 0 + let maxConcurrentMerges = 0 + let refFormatChecks = 0 + vi.spyOn(target, 'git').mockImplementation(async (args, cwd, opts) => { + if (args.includes('check-ref-format')) { + const result = await realGit(args, cwd, opts) + refFormatChecks += 1 + return result } - if (args[0] === 'rev-parse' && args[2] === 'refs/remotes/origin/main^{commit}') { - return { stdout: 'remote-oid\n', stderr: '' } + if (!args.includes('merge')) { + return realGit(args, cwd, opts) } - if (args[0] === 'rev-parse') { - return { stdout: 'old-local-oid\n', stderr: '' } + merges += 1 + activeMerges += 1 + maxConcurrentMerges = Math.max(maxConcurrentMerges, activeMerges) + try { + if (merges === 1) { + await mergeHeld + } + return await realGit(args, cwd, opts) + } finally { + activeMerges -= 1 } - if (args[0] === 'merge-base') { - return { stdout: '', stderr: '' } + }) + + const results: Promise[] = [] + for (const [index, remote] of remotes.entries()) { + results.push( + relay.callRequest('git.refreshLocalBaseRefForWorktreeCreate', { + repoPath: tmpDir, + fullRef: branchRef, + remoteTrackingRef: `refs/remotes/${remote}/main` + }) + ) + if (index === 0) { + await vi.waitFor(() => expect(merges).toBe(1)) + } else { + await vi.waitFor(() => expect(refFormatChecks).toBe(2 * (index + 1))) + await new Promise((resolve) => setTimeout(resolve, 20)) } + } + releaseMerge() + return { results: await Promise.all(results), maxConcurrentMerges: () => maxConcurrentMerges } + } + + it('moves the branch to a target a later client asked for from another remote', async () => { + const { branchRef, upstreamSha } = initForkRepo() + + const { results, maxConcurrentMerges } = await refreshWhileFirstMergeHeld(branchRef, [ + 'origin', + 'upstream', + 'origin' + ]) + + const owner = reportedWorktreePath(tmpDir) + // The third is the existing ahead-of-requested-remote rule (local is past origin/main), not a sharing artifact. + expect(results).toEqual([ + { status: 'updated', ownerWorktreePath: owner }, + { status: 'updated', ownerWorktreePath: owner }, + { status: 'skipped_not_fast_forward' } + ]) + expect(revParse('HEAD')).toBe(upstreamSha) + expect(maxConcurrentMerges()).toBe(1) + }) + + it('never answers a client with the outcome of another remote target', async () => { + const { branchRef, upstreamSha } = initForkRepo() + + const { results, maxConcurrentMerges } = await refreshWhileFirstMergeHeld(branchRef, [ + 'upstream', + 'upstream', + 'origin' + ]) + + // The third is the existing ahead-of-requested-remote rule (local is past origin/main), not a sharing artifact. + expect(results).toEqual([ + { status: 'updated', ownerWorktreePath: reportedWorktreePath(tmpDir) }, + { status: 'nothing_to_do' }, + { status: 'skipped_not_fast_forward' } + ]) + expect(revParse('HEAD')).toBe(upstreamSha) + expect(maxConcurrentMerges()).toBe(1) + }) + + it('reports an error without mutating when worktree ownership cannot be listed', async () => { + const { localDispatcher, gitMock } = setupMockedRefreshHandler() + mockBehindOwnerGit(gitMock) + const base = gitMock.getMockImplementation()! + gitMock.mockImplementation(async (args, cwd) => { if (args[0] === 'worktree') { throw new Error('worktree list failed') } - throw new Error(`unexpected git call: ${args.join(' ')}`) + return base(args, cwd) }) await expect( @@ -373,19 +463,12 @@ describe('GitHandler', () => { fullRef: 'refs/heads/main', remoteTrackingRef: 'refs/remotes/origin/main' }) - ).rejects.toThrow('worktree list failed') + ).resolves.toEqual({ status: 'skipped_error' }) - expect(gitMock.mock.calls.map((call) => call[0])).not.toContainEqual([ - 'update-ref', - 'refs/heads/main', - 'refs/remotes/origin/main', - 'old-local-oid' - ]) - expect(gitMock.mock.calls.map((call) => call[0])).not.toContainEqual([ - 'reset', - '--hard', - 'refs/heads/main' - ]) + const commands = gitMock.mock.calls.map(([args]) => args) + expect(commands.some((args) => args.includes('merge') || args[0] === 'update-ref')).toBe( + false + ) }) }) @@ -496,46 +579,6 @@ describe('GitHandler', () => { ]) }) - it('qualifies slash-containing local branch names when no remote ref matches', async () => { - const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) - gitMock.mockRejectedValueOnce(new Error('no remote ref')) // rev-parse refs/remotes/release/main^{commit} - gitMock.mockResolvedValueOnce({ stdout: 'abc123\n', stderr: '' }) // rev-parse refs/heads/release/main^{commit} - gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add - gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // config --local --replace-all branch..base - gitMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // --get unset - gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // --local set - - await localDispatcher.callRequest('git.addWorktree', { - repoPath: '/relay/repo', - branchName: 'feature/release', - targetDir: '/relay/wt', - base: 'release/main' - }) - - expect(gitMock.mock.calls.map((c) => c[0])).toEqual([ - ['rev-parse', '--verify', '--quiet', 'refs/remotes/release/main^{commit}'], - ['rev-parse', '--verify', '--quiet', 'refs/heads/release/main^{commit}'], - [ - 'worktree', - 'add', - '--no-track', - '-b', - 'feature/release', - '/relay/wt', - 'refs/heads/release/main' - ], - [ - 'config', - '--local', - '--replace-all', - 'branch.feature/release.base', - 'refs/heads/release/main' - ], - ['config', '--get', 'push.autoSetupRemote'], - ['config', '--local', 'push.autoSetupRemote', 'true'] - ]) - }) - it('passes --no-checkout when sparse setup will checkout after configuration', async () => { const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt']) gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // rev-parse refs/remotes/origin/main diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index 590b22636baa..c6ba3142585f 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -73,6 +73,7 @@ describe('GitHandler', () => { expect(methods).toContain('git.removeWorktree') expect(methods).toContain('git.worktreeIsClean') expect(methods).toContain('git.refreshLocalBaseRefForWorktreeCreate') + expect(methods).toContain('git.inspectLocalBaseRefForWorktreeCreate') expect(methods).toContain('git.markRemoteOrcaCreated') expect(methods).toContain('git.renameCurrentBranch') expect(methods).toContain('git.forceDeletePreservedBranch') diff --git a/src/relay/opencode-canonical-config.ts b/src/relay/opencode-canonical-config.ts index 38682d3f2746..8cdfa7d5536d 100644 --- a/src/relay/opencode-canonical-config.ts +++ b/src/relay/opencode-canonical-config.ts @@ -1,7 +1,8 @@ -import { mkdirSync, unlinkSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs' import { isAbsolute, join, relative, resolve } from 'node:path' import { resolveOpenCodeConfigDirectory } from '../shared/opencode-config-directory' import { isInstalledOpenCodePluginCurrent } from '../shared/opencode-installed-plugin' +import { writeOpenCodeTuiPlugin } from '../shared/opencode-tui-plugin-install' const RELAY_HOOKS_DIR = '.orca-relay' @@ -11,14 +12,19 @@ export function installOpenCodePluginInCanonicalConfig( source: string, agent: OpenCodeAgent, environment: NodeJS.ProcessEnv | Record, - homeDir: string + homeDir: string, + onlyIfInstalled = false ): boolean { try { const configDir = resolveOpenCodeConfigDirectory(environment, homeDir) const pluginFileName = agent === 'opencode2' ? 'orca-opencode2-status.js' : 'orca-opencode-status.js' const pluginPath = join(configDir, 'plugins', pluginFileName) + if (onlyIfInstalled && !existsSync(pluginPath)) { + return false + } mkdirSync(join(configDir, 'plugins'), { recursive: true }) + writeOpenCodeTuiPlugin(join(configDir, 'plugins'), pluginFileName, source) if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) { try { unlinkSync(pluginPath) diff --git a/src/relay/opencode-hook-selection.test.ts b/src/relay/opencode-hook-selection.test.ts index 336db284a476..6e47c28bc364 100644 --- a/src/relay/opencode-hook-selection.test.ts +++ b/src/relay/opencode-hook-selection.test.ts @@ -135,6 +135,17 @@ describe('relay OpenCode source selection on real fixture files', () => { expect((await spawn()).ORCA_OPENCODE_AGENT).toBe('opencode') expect(readFileSync(original, 'utf8')).toBe('// refreshed v1') }) + // Why: a running OpenCode 2 service reloads a changed plugin file, so connecting must upgrade it. + it('refreshes an existing canonical plugin on install without creating a new one', async () => { + const dir = join(root, 'xdg', 'opencode') + mkdirSync(join(dir, 'plugins'), { recursive: true }) + writeFileSync(plugin(dir, 'opencode2'), '// old v2') + await install('// v1', '// v2') + expect(readFileSync(plugin(dir, 'opencode2'), 'utf8')).toBe('// v2') + const tuiEntry = join(dir, 'plugins', 'orca-opencode2-status-tui', 'tui.js') + expect(readFileSync(tuiEntry, 'utf8')).toBe('// v2') + expect(existsSync(plugin(dir, 'opencode'))).toBe(false) + }) it('restores the real custom source when all OpenCode sources are revoked', async () => { await install('// v1', '// v2') const first = await spawn({ env: { OPENCODE_CONFIG_DIR: custom } }) diff --git a/src/relay/plugin-overlay.test.ts b/src/relay/plugin-overlay.test.ts index d4d38a31fba5..9129e202287b 100644 --- a/src/relay/plugin-overlay.test.ts +++ b/src/relay/plugin-overlay.test.ts @@ -75,6 +75,28 @@ describe('PluginOverlayManager', () => { expect( readFileSync(join(homeDir, 'xdg', 'opencode', 'plugins', 'orca-opencode2-status.js'), 'utf8') ).toBe('v2 plugin') + const tuiEntry = join( + homeDir, + 'xdg', + 'opencode', + 'plugins', + 'orca-opencode2-status-tui', + 'tui.js' + ) + expect(readFileSync(tuiEntry, 'utf8')).toBe('v2 plugin') + expect( + readFileSync( + join(homeDir, 'xdg', 'opencode', 'plugins', 'orca-opencode-status-tui', 'tui.js'), + 'utf8' + ) + ).toBe('v1 plugin') + + // Why: OpenCode 2 reloads plugins when a plugins/ entry is rewritten, so an unchanged + // TUI copy must not be touched on the next launch. + const past = new Date('2020-01-01T00:00:00Z') + utimesSync(tuiEntry, past, past) + manager.installOpenCodePlugin('opencode2', { XDG_CONFIG_HOME: join(homeDir, 'xdg') }) + expect(statSync(tuiEntry).mtimeMs).toBe(past.getTime()) }) // Why: OpenCode 2 reloads a plugin whose file mtime changed, even with unchanged bytes. @@ -121,6 +143,21 @@ describe('PluginOverlayManager', () => { } ) + // Why: a service that reloads between the two writes must already find the TUI copy and stand down. + it('writes the TUI copy before the server plugin file', () => { + manager.setSources({ opencode2PluginSource: 'v2 plugin' }) + const pluginsDir = join(homeDir, 'xdg', 'opencode', 'plugins') + // A directory in the server file's place makes that write fail. + mkdirSync(join(pluginsDir, 'orca-opencode2-status.js'), { recursive: true }) + + expect( + manager.installOpenCodePlugin('opencode2', { XDG_CONFIG_HOME: join(homeDir, 'xdg') }) + ).toBe(false) + expect(readFileSync(join(pluginsDir, 'orca-opencode2-status-tui', 'tui.js'), 'utf8')).toBe( + 'v2 plugin' + ) + }) + it('mirrors a preexisting remote OpenCode config dir before adding Orca plugin', () => { const userConfigDir = join(homeDir, 'company-opencode') mkdirSync(join(userConfigDir, 'plugins'), { recursive: true }) @@ -154,13 +191,23 @@ describe('PluginOverlayManager', () => { const userConfigDir = join(homeDir, '.config', 'opencode') mkdirSync(join(userConfigDir, 'plugins'), { recursive: true }) writeFileSync(join(userConfigDir, 'plugins', stale), 'stale other-major plugin') + const staleTui = stale.replace(/\.js$/, '-tui') + mkdirSync(join(userConfigDir, 'plugins', staleTui)) + writeFileSync(join(userConfigDir, 'plugins', staleTui, 'tui.js'), 'stale other-major plugin') writeFileSync(join(userConfigDir, 'plugins', 'user-plugin.js'), 'user plugin') manager.setSources({ opencodePluginSource: 'v1', opencode2PluginSource: 'v2' }) const dir = manager.materializeOpenCode('tab-1:0', userConfigDir, agent) expect(dir).not.toBeNull() - expect(readdirSync(join(dir!, 'plugins')).sort()).toEqual([own, 'user-plugin.js'].sort()) + const ownTui = own.replace(/\.js$/, '-tui') + expect(readdirSync(join(dir!, 'plugins')).sort()).toEqual( + [own, ownTui, 'user-plugin.js'].sort() + ) + // The TUI copy is the same module; its setup() tells a TUI context from a server one. + expect(readFileSync(join(dir!, 'plugins', ownTui, 'tui.js'), 'utf8')).toBe( + agent === 'opencode2' ? 'v2' : 'v1' + ) } ) diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts index 001309c1f931..35455d06859e 100644 --- a/src/relay/plugin-overlay.ts +++ b/src/relay/plugin-overlay.ts @@ -37,6 +37,10 @@ import { type OpenCodeAgent } from './opencode-canonical-config' import { writeRelayOmpStatusExtension } from './omp-status-extension' +import { + openCodeTuiPluginDirName, + writeOpenCodeTuiPlugin +} from '../shared/opencode-tui-plugin-install' type LegacyOverlayAgentKind = Exclude const RELAY_HOOKS_DIR = '.orca-relay' const OPENCODE_OVERLAY_SUBDIR = 'opencode-overlays' @@ -47,6 +51,10 @@ const PI_OVERLAY_SUBDIR_BY_KIND: Record = { } const OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const OPENCODE2_PLUGIN_FILE = 'orca-opencode2-status.js' +// Orca's own entries (either major, file and TUI copy) are never mirrored from user config. +const ORCA_OPENCODE_PLUGIN_ENTRIES = new Set( + [OPENCODE_PLUGIN_FILE, OPENCODE2_PLUGIN_FILE].flatMap((f) => [f, openCodeTuiPluginDirName(f)]) +) const PI_EXTENSION_FILE = 'orca-agent-status.ts' const PI_AGENT_SUBDIR = 'agent' const OMP_MANAGED_STATUS_EXTENSION_DIR = 'omp-managed-status-extension' @@ -170,11 +178,7 @@ export class PluginOverlayManager { const source = this.piExtensionSources[kind] return source ?? (kind === 'omp' ? this.piExtensionSources.pi : null) } - private mirrorOpenCodeConfig( - sourceDir: string, - overlayDir: string, - pluginFileName: string - ): void { + private mirrorOpenCodeConfig(sourceDir: string, overlayDir: string): void { for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { const sourcePath = join(sourceDir, entry.name) if (entry.name === 'plugins') { @@ -192,11 +196,7 @@ export class PluginOverlayManager { const overlayPluginsDir = join(overlayDir, 'plugins') mkdirSync(overlayPluginsDir, { recursive: true }) for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { - if ( - pluginEntry.name === pluginFileName || - pluginEntry.name === OPENCODE_PLUGIN_FILE || - pluginEntry.name === OPENCODE2_PLUGIN_FILE - ) { + if (ORCA_OPENCODE_PLUGIN_ENTRIES.has(pluginEntry.name)) { continue } mirrorEntry( @@ -214,6 +214,7 @@ export class PluginOverlayManager { const pluginsDir = join(overlayDir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) const pluginPath = join(pluginsDir, pluginFileName) + writeOpenCodeTuiPlugin(pluginsDir, pluginFileName, source) try { unlinkSync(pluginPath) } catch { @@ -250,7 +251,7 @@ export class PluginOverlayManager { // Why: OPENCODE_CONFIG_DIR is a single config root. Mirror the user's // remote root into the overlay before adding Orca's plugin so status // reporting does not hide their auth, models, keybinds, or plugins. - this.mirrorOpenCodeConfig(existingConfigDir, dir, pluginFileName) + this.mirrorOpenCodeConfig(existingConfigDir, dir) } this.writeOpenCodePlugin(dir, pluginFileName, source) return dir diff --git a/src/relay/protocol-handshake.test.ts b/src/relay/protocol-handshake.test.ts index 4b4695877b72..92a6a64c9ce0 100644 --- a/src/relay/protocol-handshake.test.ts +++ b/src/relay/protocol-handshake.test.ts @@ -139,6 +139,28 @@ describe('handshake framing', () => { }) }) + it('carries the optional informational runtime on handshake-ok', () => { + const runtime = { kind: 'pinned-node', version: '24.21.0' } + const payload = Buffer.from( + JSON.stringify({ type: 'orca-relay-handshake-ok', version: '0.1.0', runtime }) + ) + expect(parseHandshakeMessage(payload)).toEqual({ + type: 'orca-relay-handshake-ok', + version: '0.1.0', + runtime + }) + }) + + it.each([['pinned-node'], [{ kind: 1, version: '24' }], [null]])( + 'refuses a present-but-malformed runtime %j', + (runtime) => { + const payload = Buffer.from( + JSON.stringify({ type: 'orca-relay-handshake-ok', version: '0.1.0', runtime }) + ) + expect(() => parseHandshakeMessage(payload)).toThrow('runtime') + } + ) + it('handshake frames use a distinct MessageType from Regular and KeepAlive', () => { expect(MessageType.Handshake).not.toBe(MessageType.Regular) expect(MessageType.Handshake).not.toBe(MessageType.KeepAlive) diff --git a/src/relay/protocol.ts b/src/relay/protocol.ts index 84656fed3cbe..4a4f135f4eff 100644 --- a/src/relay/protocol.ts +++ b/src/relay/protocol.ts @@ -39,12 +39,16 @@ export const MessageType = { // stale daemon. export type HandshakeMessage = | { type: 'orca-relay-handshake'; version: string; endpointCredential?: string } - | { type: 'orca-relay-handshake-ok'; version: string } + // Why optional and informational: bridge and daemon already match exactly on `version`; + // this only names the runtime executing the daemon (design D8.1). + | { type: 'orca-relay-handshake-ok'; version: string; runtime?: RelayHandshakeRuntime } | { type: 'orca-relay-handshake-mismatch'; expected: string; got: string } // Why a distinct reply: the bridge exits with its own code so the client can tell a refused // credential from a crashed relay. Old bridges reject the unknown type and exit 1 pre-sentinel. | { type: 'orca-relay-handshake-credential-mismatch' } +export type RelayHandshakeRuntime = { kind: string; version: string } + export function encodeHandshakeFrame(msg: HandshakeMessage): Buffer { const payload = Buffer.from(JSON.stringify(msg), 'utf-8') return encodeFrame(MessageType.Handshake, 0, 0, payload) @@ -104,6 +108,19 @@ export function parseHandshakeMessage(payload: Buffer): HandshakeMessage { throw new Error(`Handshake field ${field} is not a string`) } } + if (t === 'orca-relay-handshake-ok' && msg.runtime !== undefined) { + const runtime = msg.runtime + if ( + typeof runtime !== 'object' || + runtime === null || + !('kind' in runtime) || + typeof runtime.kind !== 'string' || + !('version' in runtime) || + typeof runtime.version !== 'string' + ) { + throw new Error('Handshake field runtime is not a {kind, version} object') + } + } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this is the one place the shape is proved: the type is one of the four literals and every field the union declares has been checked to be a string. return msg as unknown as HandshakeMessage } diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index b6714a956b2a..cb88537de449 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -1,5 +1,7 @@ /* oxlint-disable max-lines */ import { resolveSynchronizedOutputSafeSplit } from '../shared/terminal-synchronized-output-scan' +import { createTerminalTitleTracker } from '../shared/terminal-output-side-effects' +import { getDecorativeTitleGateKey } from '../shared/agent-decorative-title-signature' import { FreebuffStatusProjection } from './freebuff-status-projection' import { applyRelayAgentWorkspaceTrust } from './agent-workspace-trust-spawn' import type { IPty } from 'node-pty' @@ -127,6 +129,8 @@ import { } from './terminal-history' import { isFlattenedNodePtyLoaderMessage } from '../main/orcad/node-pty-loader-diagnosis' import { collectNodePtyUnavailableDiagnosis } from './node-pty-binding-survey' +import { describeRelayRuntime } from './relay-runtime-identity' +import { relayConptyDllSpawnOptions } from './relay-windows-conpty' import { formatNodePtyUnavailableMessage, toTerminalUnavailableCause @@ -642,6 +646,10 @@ export class PtyHandler { } } + private conptyDllSpawnOptions(): { useConptyDll: true } | Record { + return relayConptyDllSpawnOptions(this.relayNodePtyDir(), describeRelayRuntime().kind) + } + /** Where the relay's own node-pty lives — the deployed bundle dir, never cwd. */ private relayNodePtyDir(): string { // Packaged relays live under Resources/relay while runtime dependencies are @@ -715,6 +723,12 @@ export class PtyHandler { return this.graceTimeMs } + private agentPresenceTrigger: ((paneKey: string) => void) | null = null + + setAgentPresenceTrigger(listener: ((paneKey: string) => void) | null): void { + this.agentPresenceTrigger = listener + } + /** Subscribe to PTY-exit events (relay-hook server uses this to evict per-paneKey caches). */ setExitListener(listener: PtyExitListener | null): void { this.exitListener = listener @@ -1019,7 +1033,25 @@ export class PtyHandler { } }) } + const recheckAgentPresence = (): void => { + if (managed.paneKey) { + this.agentPresenceTrigger?.(managed.paneKey) + } + } + let lastTitleGateKey: string | null = null + const presenceTriggers = createTerminalTitleTracker({ + onTitle: (normalizedTitle, rawTitle, meta) => { + // Why: spinner frames arrive several times a second; only a real title change re-checks. + const gateKey = getDecorativeTitleGateKey(rawTitle, normalizedTitle) + if (gateKey !== lastTitleGateKey && !meta?.staleWorkingTitleClear) { + recheckAgentPresence() + } + lastTitleGateKey = gateKey + }, + onCommandFinished: recheckAgentPresence + }) managed.pty.onData((data: string) => { + presenceTriggers.handleChunk(data) const startup = managed.startupCommand if (startup?.waitForShellReady && startup.outputScanState && !startup.delivered) { const scanned = scanShellStartupOutput(startup.outputScanState, data) @@ -1041,6 +1073,7 @@ export class PtyHandler { } }) managed.pty.onExit(({ exitCode }: { exitCode: number }) => { + presenceTriggers.dispose() managed.physicalExit?.markExited() if (managed.disposed) { return @@ -2016,7 +2049,8 @@ export class PtyHandler { ...spawnEnv, [SHELL_STARTUP_FEATURE_ENV]: '', ...shellLaunch.env - } + }, + ...this.conptyDllSpawnOptions() }) } catch (error) { // Why: Windows loads conpty.node only on first spawn, so handle that late binding failure here. @@ -3140,7 +3174,8 @@ export class PtyHandler { ...spawnEnv, [SHELL_STARTUP_FEATURE_ENV]: '', ...shellLaunch.env - } + }, + ...this.conptyDllSpawnOptions() }) } catch (error) { // Why skip rather than retry the host default shell: the stored override diff --git a/src/relay/relay-agent-hook-runtime.ts b/src/relay/relay-agent-hook-runtime.ts index bc1cef971370..1dcda669f7d9 100644 --- a/src/relay/relay-agent-hook-runtime.ts +++ b/src/relay/relay-agent-hook-runtime.ts @@ -1,8 +1,10 @@ +import { homedir } from 'node:os' import type { RelayDispatcher } from './dispatcher' import type { PtyEnvAugmenter, PtyHandler } from './pty-handler' import { RelayAgentHookServer } from './agent-hook-server' import { endpointDirForRelaySocket } from './agent-hook-endpoint-coordinates' import { PluginOverlayManager } from './plugin-overlay' +import { installOpenCodePluginInCanonicalConfig } from './opencode-canonical-config' import { AGENT_HOOK_INSTALL_PLUGINS_METHOD, AGENT_HOOK_REQUEST_REPLAY_METHOD @@ -64,6 +66,9 @@ export class RelayAgentHookRuntime { } private registerPtyEnvironment(): void { + this.ptyHandler.setAgentPresenceTrigger((paneKey) => { + void this.hookServer.checkAgentPresence(paneKey) + }) this.ptyHandler.addEnvAugmenter(() => this.hookServer.buildPtyEnv()) this.ptyHandler.addEnvAugmenter((context) => this.buildPluginEnvironment(context)) this.ptyHandler.setExitListener(({ paneKey, id }) => { @@ -195,6 +200,16 @@ export class RelayAgentHookRuntime { ompExtensionSource: typeof omp === 'string' ? omp : undefined, primeAgentExtensionSource: typeof primeAgent === 'string' ? primeAgent : undefined }) + // Why: a running OpenCode 2 service reloads a changed plugin file, so an Orca upgrade + // reaches it on connect instead of at the next pane spawn. Never creates an install. + for (const [agent, source] of [ + ['opencode', opencode], + ['opencode2', opencode2] + ] as const) { + if (typeof source === 'string' && source) { + installOpenCodePluginInCanonicalConfig(source, agent, process.env, homedir(), true) + } + } return { installed: { opencode: this.pluginOverlay.hasOpenCodeSource(), diff --git a/src/relay/relay-agent-presence.test.ts b/src/relay/relay-agent-presence.test.ts new file mode 100644 index 000000000000..87fac06e8e82 --- /dev/null +++ b/src/relay/relay-agent-presence.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type * as RetrySchedulerModule from './agent-hook-result-retry-scheduler' +import type { AgentHookResultRetryHost } from './agent-hook-result-retry-scheduler' +import { RelayAgentHookServer } from './agent-hook-server' +import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' +import { makePaneKey } from '../shared/stable-pane-id' + +const probe = vi.hoisted(() => + vi.fn(async (): Promise<'live' | 'unverifiable' | 'exited'> => 'unverifiable') +) +vi.mock('../shared/agent-process-presence-probe', () => ({ probeAgentProcessPresence: probe })) +const retryHosts = vi.hoisted(() => { + const hosts: AgentHookResultRetryHost[] = [] + return hosts +}) +vi.mock('./agent-hook-result-retry-scheduler', async (importOriginal) => { + const actual = await importOriginal() + return { + AgentHookResultRetryScheduler: class extends actual.AgentHookResultRetryScheduler { + constructor(host: AgentHookResultRetryHost) { + super(host) + retryHosts.push(host) + } + } + } +}) + +afterEach(() => { + retryHosts.length = 0 + probe.mockReset() + probe.mockResolvedValue('unverifiable') +}) + +const paneKey = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') + +describe('relay process presence', () => { + it('keeps unavailable reads, preserves resume, and publishes a real exit without a window', async () => { + const dir = await mkdtemp(join(tmpdir(), 'relay-presence-')) + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + try { + await server.start() + const { port, token } = server.getCoordinates() + const post = async (event: string, session: string, reason?: string, pid = 4001) => { + const response = await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Orca-Agent-Hook-Token': token }, + body: JSON.stringify({ + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agentProcess: JSON.stringify({ + pid, + platform: process.platform, + startTime: `birth-${pid}` + }), + payload: { hook_event_name: event, session_id: session, source: 'startup', reason } + }) + }) + expect(response.status).toBe(204) + } + await post('SessionStart', 'a') + expect(forward.mock.lastCall?.[0].agentPresence?.process?.pid).toBe(4001) + expect(probe).not.toHaveBeenCalled() + const retryHost = retryHosts[0] + const original = retryHost.state.lastStatusByPaneKey.get(paneKey) + if (!original) { + throw new Error('missing hook status') + } + retryHost.applyEvent( + { ...original, payload: { ...original.payload, lastAssistantMessage: 'late result' } }, + 'claude' + ) + await Promise.resolve() + expect(forward.mock.lastCall?.[0].payload.lastAssistantMessage).toBe('late result') + expect(probe).not.toHaveBeenCalled() + + await server.checkAgentPresence(paneKey) + expect(forward.mock.lastCall?.[0].agentPresence?.ended).toBeUndefined() + await post('SessionEnd', 'a', 'resume') + await post('SessionStart', 'b') + await post('SessionEnd', 'nested', 'other', 4002) + expect(forward.mock.lastCall?.[0].agentPresence?.ended).toBeUndefined() + await post('SessionEnd', 'b', 'prompt_input_exit') + expect(forward.mock.lastCall?.[0].agentPresence?.ended).toBe(true) + expect(forward.mock.lastCall?.[0].providerSessionOnly).toBe(true) + await post('SessionStart', 'c', undefined, 4003) + expect(forward.mock.lastCall?.[0].agentPresence?.process?.pid).toBe(4003) + probe.mockResolvedValue('exited') + await server.checkAgentPresence(paneKey) + expect(forward.mock.lastCall?.[0].agentPresence?.ended).toBe(true) + expect(forward.mock.lastCall?.[0].providerSessionOnly).toBe(true) + } finally { + server.stop() + await rm(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/relay/relay-agent-presence.ts b/src/relay/relay-agent-presence.ts new file mode 100644 index 000000000000..f32278a1309d --- /dev/null +++ b/src/relay/relay-agent-presence.ts @@ -0,0 +1,34 @@ +import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' +import { probeAgentProcessPresence } from '../shared/agent-process-presence-probe' + +export class RelayAgentPresence { + private readonly pending = new WeakMap>() + + check( + row: AgentHookEventPayload | undefined, + current: () => AgentHookEventPayload | undefined, + publish: (event: AgentHookEventPayload) => void + ): Promise { + if (!row?.agentPresence?.process || row.agentPresence.ended) { + return Promise.resolve() + } + const existing = this.pending.get(row) + if (existing) { + return existing + } + const presence = row.agentPresence + const check = probeAgentProcessPresence(presence.process) + .then((verdict) => { + if (verdict === 'exited' && current() === row) { + publish({ + ...row, + hookEventName: 'AgentProcessExit', + agentPresence: { ...presence, ended: true } + }) + } + }) + .finally(() => this.pending.delete(row)) + this.pending.set(row, check) + return check + } +} diff --git a/src/relay/relay-daemon.ts b/src/relay/relay-daemon.ts index 27983176abf9..649fde3427a4 100644 --- a/src/relay/relay-daemon.ts +++ b/src/relay/relay-daemon.ts @@ -14,6 +14,7 @@ import { restrictWindowsRelayEndpointCredential } from './relay-endpoint-credential-publication' import { SKILL_RELAY_CAPABILITIES } from './skill-install-handler' +import { publishRelayPid } from './relay-pid-publication' export async function runRelayDaemon(options: RelayLaunchOptions): Promise { if (options.detached && options.logFile) { @@ -105,6 +106,7 @@ export async function runRelayDaemon(options: RelayLaunchOptions): Promise // exits inside start() and never reaches the credential file, so racing starters cannot // rotate the secret a surviving daemon enforces. await reconnectListener.start() + publishRelayPid() reconnectListener.setEndpointCredential(publishRelayEndpointCredential(options.credentialFile)) agentHooks.publishEndpointFile() } catch (error) { diff --git a/src/relay/relay-handshake.ts b/src/relay/relay-handshake.ts index ed76baf024d9..fb7003675092 100644 --- a/src/relay/relay-handshake.ts +++ b/src/relay/relay-handshake.ts @@ -12,6 +12,7 @@ import { type DecodedFrame } from './protocol' import { relayLogLine } from './relay-diagnostic-log' +import { describeRelayRuntime } from './relay-runtime-identity' // Why: clients treat this exit code as non-retryable; other non-zero exits are transient. export const EXIT_CODE_VERSION_MISMATCH = 42 @@ -151,7 +152,13 @@ function handleDaemonHandshakeFrame( return false } process.stderr.write(`[relay] Handshake OK from version=${msg.version}\n`) - sock.write(encodeHandshakeFrame({ type: 'orca-relay-handshake-ok', version: launchVersion })) + sock.write( + encodeHandshakeFrame({ + type: 'orca-relay-handshake-ok', + version: launchVersion, + runtime: describeRelayRuntime() + }) + ) return true } @@ -194,7 +201,8 @@ export function runConnectHandshake( process.exit(1) } if (msg.type === 'orca-relay-handshake-ok') { - process.stderr.write(`[relay-connect] Handshake OK at version=${msg.version}\n`) + const runtime = msg.runtime ? ` runtime=${msg.runtime.kind}/${msg.runtime.version}` : '' + process.stderr.write(`[relay-connect] Handshake OK at version=${msg.version}${runtime}\n`) handshakeDone = true const leftover = decoder.drain() sock.removeAllListeners('data') diff --git a/src/relay/relay-pid-publication.test.ts b/src/relay/relay-pid-publication.test.ts new file mode 100644 index 000000000000..f06640e14607 --- /dev/null +++ b/src/relay/relay-pid-publication.test.ts @@ -0,0 +1,41 @@ +import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { RELAY_PID_FILENAME, RELAY_VERSION_FILENAME } from '../shared/relay-artifacts' +import { publishRelayPid } from './relay-pid-publication' + +describe('publishRelayPid', () => { + const directories: string[] = [] + afterEach(async () => { + await Promise.all(directories.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) + }) + + function versionDir(installed: boolean): string { + const dir = mkdtempSync(join(tmpdir(), 'relay-pid-')) + directories.push(dir) + writeFileSync(join(dir, 'relay.js'), '') + if (installed) { + writeFileSync(join(dir, RELAY_VERSION_FILENAME), '0.1.0+abc\n') + } + return dir + } + + it('records the daemon PID beside an installed relay.js', () => { + const dir = versionDir(true) + publishRelayPid(join(dir, 'relay.js')) + expect(readFileSync(join(dir, RELAY_PID_FILENAME), 'utf8')).toBe(`${process.pid}\n`) + expect(readdirSync(dir).filter((name) => name.endsWith('.tmp'))).toEqual([]) + }) + + it('leaves a build dir without .version untouched', () => { + const dir = versionDir(false) + publishRelayPid(join(dir, 'relay.js')) + expect(existsSync(join(dir, RELAY_PID_FILENAME))).toBe(false) + }) + + it('does not throw when the entry cannot be resolved', () => { + expect(() => publishRelayPid(join(tmpdir(), 'missing-relay-dir', 'relay.js'))).not.toThrow() + }) +}) diff --git a/src/relay/relay-pid-publication.ts b/src/relay/relay-pid-publication.ts new file mode 100644 index 000000000000..40152def587b --- /dev/null +++ b/src/relay/relay-pid-publication.ts @@ -0,0 +1,39 @@ +import { existsSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { RELAY_PID_FILENAME, RELAY_VERSION_FILENAME } from '../shared/relay-artifacts' +import { relayLogLine } from './relay-diagnostic-log' + +/** + * Record this daemon's PID in its version dir once it owns its socket, so version GC can + * tell a stale socket from a live daemon without connecting to one that is about to idle. + */ +export function publishRelayPid(entry: string | undefined = process.argv[1]): void { + if (!entry) { + return + } + let temporary: string | null = null + try { + const versionDir = dirname(realpathSync(entry)) + // Why: only an installed version dir carries `.version`; a dev or test build dir is not GC'd. + if (!existsSync(join(versionDir, RELAY_VERSION_FILENAME))) { + return + } + temporary = join(versionDir, `${RELAY_PID_FILENAME}.${process.pid}.tmp`) + writeFileSync(temporary, `${process.pid}\n`, { mode: 0o600 }) + // Why rename: GC must never read a half-written PID as a different, dead process. + renameSync(temporary, join(versionDir, RELAY_PID_FILENAME)) + temporary = null + } catch (error) { + relayLogLine( + `[relay] Could not record relay PID: ${error instanceof Error ? error.message : String(error)}` + ) + } finally { + if (temporary) { + try { + rmSync(temporary, { force: true }) + } catch { + // A leftover temp file is inert; GC reads only the renamed name. + } + } + } +} diff --git a/src/relay/relay-runtime-identity.ts b/src/relay/relay-runtime-identity.ts new file mode 100644 index 000000000000..35f677833e50 --- /dev/null +++ b/src/relay/relay-runtime-identity.ts @@ -0,0 +1,28 @@ +import path from 'node:path' +import process from 'node:process' +import type { RelayRuntimeKind } from '../shared/relay-runtime-self-test-report' + +const RUNTIME_DIR_PATTERN = /^node-[0-9a-f]{64}$/ + +/** + * Which runtime is executing this relay: Orca's pinned Node lives at + * `runtimes/node-/bin/node`, or `runtimes\node-\node.exe` on Windows + * (design D5); any other path is a host Node. + */ +export function describeRelayRuntime( + execPath: string = process.execPath, + paths: Pick = path +): { + kind: RelayRuntimeKind + version: string +} { + const { basename, dirname } = paths + const inStore = (runtimeDir: string): boolean => + RUNTIME_DIR_PATTERN.test(basename(runtimeDir)) && basename(dirname(runtimeDir)) === 'runtimes' + const posix = basename(dirname(execPath)) === 'bin' && inStore(dirname(dirname(execPath))) + const windows = basename(execPath).toLowerCase() === 'node.exe' && inStore(dirname(execPath)) + return { + kind: posix || windows ? 'pinned-node' : 'host-node', + version: process.versions.node + } +} diff --git a/src/relay/relay-runtime-self-test.test.ts b/src/relay/relay-runtime-self-test.test.ts new file mode 100644 index 000000000000..41e52c07404b --- /dev/null +++ b/src/relay/relay-runtime-self-test.test.ts @@ -0,0 +1,88 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path, { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { describeRelayRuntime } from './relay-runtime-identity' +import { runRelayRuntimeSelfTest } from './relay-runtime-self-test' +import { relayConptyDllSpawnOptions } from './relay-windows-conpty' + +const nodePtyDir = dirname(require.resolve('node-pty/package.json')) +const directories: string[] = [] +afterEach(() => { + for (const dir of directories.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('relay runtime identity', () => { + it('names the pinned store layout pinned-node and anything else host-node', () => { + const sha = 'a'.repeat(64) + expect(describeRelayRuntime(`/h/.orca-remote/runtimes/node-${sha}/bin/node`).kind).toBe( + 'pinned-node' + ) + expect(describeRelayRuntime('/usr/bin/node').kind).toBe('host-node') + expect(describeRelayRuntime(`/tmp/node-${sha}/bin/node`).kind).toBe('host-node') + expect(describeRelayRuntime('/usr/bin/node').version).toBe(process.versions.node) + }) + + it('recognizes node.exe at the Windows store root', () => { + const sha = 'b'.repeat(64) + const win = (execPath: string): string => describeRelayRuntime(execPath, path.win32).kind + expect(win(`C:\\Users\\u\\.orca-remote\\runtimes\\node-${sha}\\node.exe`)).toBe('pinned-node') + expect(win(`C:/Users/u/.orca-remote/runtimes/node-${sha}/NODE.EXE`)).toBe('pinned-node') + expect(win('C:\\Program Files\\nodejs\\node.exe')).toBe('host-node') + expect(win(`C:\\Users\\u\\runtimes\\node-${sha}\\bun.exe`)).toBe('host-node') + }) +}) + +function fakeWindowsNodePty(withConpty: boolean): string { + const dir = mkdtempSync(join(tmpdir(), 'relay-win-pty-')) + directories.push(dir) + const release = join(dir, 'build', 'Release') + mkdirSync(join(release, 'conpty'), { recursive: true }) + writeFileSync(join(release, 'conpty.node'), 'not a real addon') + if (withConpty) { + writeFileSync(join(release, 'conpty', 'conpty.dll'), 'dll') + writeFileSync(join(release, 'conpty', 'OpenConsole.exe'), 'exe') + } + return dir +} + +describe('Windows bundled ConPTY', () => { + it('opts only a pinned-Node relay on win32 into the bundled DLL', () => { + const dir = fakeWindowsNodePty(true) + expect(relayConptyDllSpawnOptions(dir, 'pinned-node', 'win32')).toEqual({ useConptyDll: true }) + expect(relayConptyDllSpawnOptions(dir, 'host-node', 'win32')).toEqual({}) + expect(relayConptyDllSpawnOptions(dir, 'pinned-node', 'linux')).toEqual({}) + expect(relayConptyDllSpawnOptions(fakeWindowsNodePty(false), 'pinned-node', 'win32')).toEqual( + {} + ) + }) + + it('reports a quarantined ConPTY pair as a load failure before touching the addon', async () => { + const report = await runRelayRuntimeSelfTest('n', fakeWindowsNodePty(false), 'win32') + expect(report).toMatchObject({ ok: false, stage: 'load' }) + expect(report.ok === false && report.error).toMatch(/bundled ConPTY file missing after upload/) + }) + + it('looks for conpty.node, not pty.node, on win32', async () => { + const report = await runRelayRuntimeSelfTest('n', fakeWindowsNodePty(true), 'win32') + // The fake addon cannot load here; reaching dlopen proves the Windows binding was chosen. + expect(report).toMatchObject({ ok: false, stage: 'load' }) + expect(report.ok === false && report.error).not.toMatch(/no PTY binding/) + }) +}) + +describe.skipIf(process.platform === 'win32')('relay runtime self-test', () => { + it('loads pty.node and opens and closes a PTY, echoing the nonce', async () => { + const report = await runRelayRuntimeSelfTest('nonce-1', nodePtyDir) + expect(report).toMatchObject({ nonce: 'nonce-1', ok: true, node: process.version }) + }) + + it('reports a load-stage failure when the binding is absent', async () => { + const empty = mkdtempSync(join(tmpdir(), 'relay-selftest-')) + directories.push(empty) + const report = await runRelayRuntimeSelfTest('n', empty) + expect(report).toMatchObject({ ok: false, stage: 'load' }) + }) +}) diff --git a/src/relay/relay-runtime-self-test.ts b/src/relay/relay-runtime-self-test.ts new file mode 100644 index 000000000000..271dc7b853c9 --- /dev/null +++ b/src/relay/relay-runtime-self-test.ts @@ -0,0 +1,121 @@ +/** + * `relay.js --orca-runtime-selftest `: prove this runtime can load the shipped + * node-pty binding and open a PTY before a client launches a daemon on it (design D5). + * + * Why a separate process run rather than trusting the daemon's first spawn: a runtime + * that cannot load the addon must be refused before a daemon holds the version dir, so + * the client can still choose another runtime for that directory. + */ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import process from 'node:process' +import type * as NodePty from 'node-pty' +import { detectNativeHostAbi } from '../main/orcad/native-host-abi' +import { + RELAY_RUNTIME_SELF_TEST_PREFIX, + type RelayRuntimeSelfTestReport +} from '../shared/relay-runtime-self-test-report' +import { describeRelayRuntime } from './relay-runtime-identity' +import { relayBundledConptyPaths } from './relay-windows-conpty' + +const PTY_EXIT_TIMEOUT_MS = 10_000 + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function ptyBindingPath(nodePtyDir: string, platform: NodeJS.Platform): string | null { + const binding = platform === 'win32' ? 'conpty.node' : 'pty.node' + for (const dir of ['build/Release', 'build/Debug']) { + const candidate = join(nodePtyDir, dir, binding) + if (existsSync(candidate)) { + return candidate + } + } + return null +} + +function openAndClosePty(pty: typeof NodePty, platform: NodeJS.Platform): Promise { + return new Promise((resolve, reject) => { + const common = { name: 'xterm', cols: 80, rows: 24, cwd: process.cwd() } + const child = + platform === 'win32' + ? // Why the bundled DLL: it is the ConPTY a pinned relay's terminals run on. + pty.spawn(process.env.ComSpec ?? 'cmd.exe', ['/d', '/c', 'exit 0'], { + ...common, + env: { ...process.env }, + useConptyDll: true + }) + : pty.spawn('/bin/sh', ['-c', 'exit 0'], { + ...common, + env: { PATH: process.env.PATH ?? '/usr/bin:/bin' } + }) + const timer = setTimeout(() => { + child.kill() + reject(new Error(`the PTY child did not exit within ${PTY_EXIT_TIMEOUT_MS / 1000}s`)) + }, PTY_EXIT_TIMEOUT_MS) + child.onExit(() => { + clearTimeout(timer) + resolve() + }) + }) +} + +export async function runRelayRuntimeSelfTest( + nonce: string, + nodePtyDir: string = join(__dirname, 'node_modules', 'node-pty'), + platform: NodeJS.Platform = process.platform +): Promise { + const abi = detectNativeHostAbi() + const base = { + nonce, + node: process.version, + napi: process.versions.napi ?? null, + glibcVersionRuntime: abi.glibcVersion, + runtime: describeRelayRuntime().kind + } + const binding = ptyBindingPath(nodePtyDir, platform) + if (!binding) { + return { ...base, ok: false, stage: 'load', error: `no PTY binding under ${nodePtyDir}` } + } + if (platform === 'win32') { + // The upload completed, so a file gone now was removed on the host (AV quarantine). + const missing = relayBundledConptyPaths(nodePtyDir).find((path) => !existsSync(path)) + if (missing) { + return { + ...base, + ok: false, + stage: 'load', + error: `bundled ConPTY file missing after upload: ${missing}` + } + } + } + try { + // Why dlopen first: node-pty's loader rethrows only its last attempt, which hides the + // dynamic loader's message the client classifies (GLIBC_x not found, missing .so). + process.dlopen({ exports: {} }, binding) + } catch (error) { + return { ...base, ok: false, stage: 'load', error: errorText(error) } + } + let pty: typeof NodePty + try { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: node-pty's own entry, the module the relay's PtyHandler requires by the same path. + pty = require(join(nodePtyDir, 'lib', 'index.js')) as typeof NodePty + } catch (error) { + return { ...base, ok: false, stage: 'load', error: errorText(error) } + } + try { + await openAndClosePty(pty, platform) + } catch (error) { + return { ...base, ok: false, stage: 'spawn', error: errorText(error) } + } + return { ...base, ok: true } +} + +export async function runRelayRuntimeSelfTestCommand(nonce: string): Promise { + const report = await runRelayRuntimeSelfTest(nonce) + process.stdout.write(`${RELAY_RUNTIME_SELF_TEST_PREFIX}${JSON.stringify(report)}\n`, () => + process.exit(0) + ) + return new Promise(() => {}) +} diff --git a/src/relay/relay-windows-conpty.ts b/src/relay/relay-windows-conpty.ts new file mode 100644 index 000000000000..39dc7889fb66 --- /dev/null +++ b/src/relay/relay-windows-conpty.ts @@ -0,0 +1,32 @@ +/** + * The bundled ConPTY a pinned-Node relay ships in its node-pty slot (design D2): conpty.dll plus + * OpenConsole.exe, the pair the desktop daemon already spawns through (`useConptyDll`). + */ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import type { RelayRuntimeKind } from '../shared/relay-runtime-self-test-report' + +export const RELAY_BUNDLED_CONPTY_FILES = ['conpty/conpty.dll', 'conpty/OpenConsole.exe'] as const + +export function relayBundledConptyPaths(nodePtyDir: string): string[] { + return RELAY_BUNDLED_CONPTY_FILES.map((file) => + join(nodePtyDir, 'build', 'Release', ...file.split('/')) + ) +} + +/** + * Only a pinned-Node relay opts in: its slot always carries the bundled pair, while a host-Node + * relay's npm tree keeps the inbox ConPTY it has always used. + */ +export function relayConptyDllSpawnOptions( + nodePtyDir: string, + runtimeKind: RelayRuntimeKind, + platform: NodeJS.Platform = process.platform +): { useConptyDll: true } | Record { + if (platform !== 'win32' || runtimeKind !== 'pinned-node') { + return {} + } + return relayBundledConptyPaths(nodePtyDir).every((path) => existsSync(path)) + ? { useConptyDll: true } + : {} +} diff --git a/src/relay/relay.ts b/src/relay/relay.ts index fd30961c330a..9f49664599b0 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -8,8 +8,15 @@ import { runRelayOrcaCliChannel } from './relay-orca-cli-channel' import { runRelayDaemon } from './relay-daemon' import { relayLogLine } from './relay-diagnostic-log' import { configureRelayBundledRipgrep } from './relay-bundled-ripgrep' +import { runRelayRuntimeSelfTestCommand } from './relay-runtime-self-test' +import { RELAY_RUNTIME_SELF_TEST_FLAG } from '../shared/relay-runtime-self-test-report' async function main(): Promise { + const selfTestFlag = process.argv.indexOf(RELAY_RUNTIME_SELF_TEST_FLAG) + if (selfTestFlag !== -1) { + await runRelayRuntimeSelfTestCommand(process.argv[selfTestFlag + 1] ?? '') + return + } const options = parseRelayLaunchOptions(process.argv) if (options.connectMode) { runRelayConnectChannel(options.sockPath, readRelayEndpointCredential(options.credentialFile)) diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts index a767a61ccf4b..97488e50fd64 100644 --- a/src/renderer/src/components/AgentStateDot.test.ts +++ b/src/renderer/src/components/AgentStateDot.test.ts @@ -96,7 +96,7 @@ describe('AgentStateDot', () => { expect(markup).not.toContain('data-agent-spinner') }) - it.each(['blocked', 'interrupted'] satisfies AgentDotState[])( + it.each(['blocked', 'failed'] satisfies AgentDotState[])( 'renders %s as a red attention dot', (state) => { const classNames = renderDotClassNames(state) @@ -106,6 +106,14 @@ describe('AgentStateDot', () => { } ) + it("renders a user's Stop as a muted dot, neither the fault red nor the idle grey", () => { + const classNames = renderDotClassNames('interrupted') + + expect(classNames).toContain('bg-muted-foreground') + expect(classNames).not.toContain('bg-red-500') + expect(classNames).not.toContain('bg-neutral-500/40') + }) + const ALL_STATES = [ 'working', 'monitoring', @@ -116,6 +124,7 @@ describe('AgentStateDot', () => { 'done', 'idle', 'unverifiable', + 'unconfirmed', 'permission' ] satisfies AgentDotState[] diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx index af8ac4efd843..44fb3dc7c103 100644 --- a/src/renderer/src/components/AgentStateDot.tsx +++ b/src/renderer/src/components/AgentStateDot.tsx @@ -35,6 +35,9 @@ export type AgentDotState = // held there, and never rendered as 'done' or 'working' — it asserts nothing about // the agent, only about what Orca last heard. | 'unverifiable' + // Why: the turn ended and Orca cannot prove how. An outcome like 'failed', drawn with the + // 'unverifiable' glyph because it too reports missing evidence, never a finish. + | 'unconfirmed' // Why: the sidebar's title-based status flow (StatusIndicator/WorktreeCard) // collapses blocked + waiting into a single "needs attention" state. Keep // this as a distinct member so that flow can render without inventing a new @@ -63,6 +66,8 @@ export function agentStateLabel(state: AgentDotState): string { return 'Idle' case 'unverifiable': return 'No recent update' + case 'unconfirmed': + return 'Couldn’t confirm' case 'permission': return 'Needs attention' } @@ -123,7 +128,7 @@ export const AgentStateDot = React.memo(function AgentStateDot({